File size: 10,504 Bytes
652a63f e727aeb 652a63f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | """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"
# Fixed sheet width. Wide enough for two 16:9 thumbnails plus a readable text
# column, and a round number in the output video's own scale.
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) # --h3-accent
WARN = (255, 138, 92)
_FONT_CANDIDATES = (
# Windows
"C:\\Windows\\Fonts\\segoeui.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\tahoma.ttf",
# Linux
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
# macOS
"/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:
# matplotlib ships DejaVu and is present in most ComfyUI installs.
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) # Pillow >= 10.1
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: # never lose a render over a picture
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)
# Thumbnails keep the source aspect; two of them share the left column.
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]) # W/H
tw = int(round(THUMB_H * ar))
# Two thumbs must not eat more than 60% of the sheet.
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
# --- measure ---------------------------------------------------------
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 # hop line
+ 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()
|