main
Caleb Publish 0.5.0 1984b40
"""The look of the run video: one dark ground, one accent colour, and the text and panel helpers every scene uses.

Everything is drawn with PIL into RGBA numpy arrays. Sizes are in pixels at 1920x1080. No text is smaller than MIN_TEXT,
so it stays readable when the video is played in a small window.
"""
import os
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

W, H = 1920, 1080
FPS = 30
BG = (13, 16, 22)          # the ground
PANEL = (21, 25, 33)       # the figures panel
CHIP = (10, 12, 17)        # dark chips over photos
LINE = (52, 60, 74)        # hairlines
INK = (238, 241, 246)      # main text
SOFT = (166, 176, 190)     # secondary text
ACCENT = (255, 184, 48)    # the one accent colour
WARN = (240, 96, 64)       # only for "not reached" marks
MIN_TEXT = 24
PANEL_X = 1340             # the evidence picture is left of this, the figures panel right of it
MARGIN = 48

# How well a net is proven, in the ladder's order. Same colours as the program's own net pictures (src/netpaint.rs, RUNGS).
RUNG_COLOURS = {
    "copper": (86, 180, 233), "via": (0, 205, 150), "inferred": (245, 170, 20),
    "group": (220, 125, 190), "none": (190, 190, 190), "conflict": (240, 80, 20),
}
RUNG_WORDS = {
    "copper": "joined by copper seen in the photos", "via": "joined through vias", "inferred": "rests on inference",
    "group": "unnamed copper joined to other unnamed copper", "none": "isolated copper with no net", "conflict": "two nets meet",
}
# Blocks are told apart by hue (Okabe-Ito, lifted for a dark ground). This is data colour, not the accent.
BLOCK_HUES = [(86, 180, 233), (245, 170, 20), (0, 205, 150), (220, 125, 190), (240, 228, 66), (70, 130, 255), (240, 80, 20), (200, 200, 200),
              (150, 220, 120), (255, 150, 150), (150, 150, 255), (120, 220, 220)]

_FONT_DIRS = ["/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/dejavu", "/usr/share/fonts/TTF", "/usr/share/fonts/truetype/liberation",
              "/usr/share/fonts/truetype/noto", "/Library/Fonts", "/System/Library/Fonts/Supplemental", "C:/Windows/Fonts"]
_FONT_NAMES = {False: ["DejaVuSans.ttf", "LiberationSans-Regular.ttf", "NotoSans-Regular.ttf", "Arial.ttf", "arial.ttf"],
               True: ["DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf", "NotoSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf"]}
_cache = {}
FONT_USED = {}


def _font_file(bold):
    env = os.environ.get("ADOM_AIFLOW_VIDEO_FONT_BOLD" if bold else "ADOM_AIFLOW_VIDEO_FONT")
    if env and os.path.isfile(env):
        return env
    for d in _FONT_DIRS:
        for n in _FONT_NAMES[bold]:
            p = os.path.join(d, n)
            if os.path.isfile(p):
                return p
    return None


def font(size, bold=False):
    """A font at this size. A system sans when one is found, else PIL's built-in scalable font, so a build never fails for a font."""
    key = (int(size), bold)
    if key not in _cache:
        f = _font_file(bold)
        FONT_USED["bold" if bold else "regular"] = f or "PIL built-in"
        _cache[key] = ImageFont.truetype(f, int(size)) if f else ImageFont.load_default(int(size))
    return _cache[key]


def text_w(s, size, bold=False):
    return font(size, bold).getlength(s)


def wrap(s, size, max_w, bold=False, max_lines=None):
    """Break text into lines no wider than max_w. With max_lines, the last line ends in three dots when text is left over."""
    words, lines, cur = s.split(), [], ""
    for w in words:
        t = (cur + " " + w).strip()
        if text_w(t, size, bold) <= max_w or not cur:
            cur = t
        else:
            lines.append(cur)
            cur = w
    if cur:
        lines.append(cur)
    if max_lines and len(lines) > max_lines:
        lines = lines[:max_lines]
        last = lines[-1]
        while last and text_w(last + " ...", size, bold) > max_w:
            last = last.rsplit(" ", 1)[0] if " " in last else last[:-1]
        lines[-1] = last + " ..."
    return lines


def fit(s, size, max_w, bold=False):
    """One line, cut with three dots when it is too long."""
    if text_w(s, size, bold) <= max_w:
        return s
    while s and text_w(s + "...", size, bold) > max_w:
        s = s[:-1]
    return s.rstrip() + "..."


class Canvas:
    """An RGBA picture to draw a panel or a chip on, then hand to the renderer as a layer."""

    def __init__(self, w, h, fill=(0, 0, 0, 0)):
        self.im = Image.new("RGBA", (int(w), int(h)), fill)
        self.d = ImageDraw.Draw(self.im)
        self.w, self.h = int(w), int(h)

    def text(self, x, y, s, size, colour=INK, bold=False, anchor="la"):
        self.d.text((x, y), s, font=font(size, bold), fill=tuple(colour) + (255,), anchor=anchor)
        return text_w(s, size, bold)

    def lines(self, x, y, lines, size, colour=INK, bold=False, gap=1.28):
        for k, ln in enumerate(lines):
            self.text(x, y + k * size * gap, ln, size, colour, bold)
        return y + len(lines) * size * gap

    def rect(self, x0, y0, x1, y1, fill, radius=0, outline=None, width=1):
        fill = tuple(fill) + (255,) if fill is not None and len(fill) == 3 else fill
        outline = tuple(outline) + (255,) if outline is not None and len(outline) == 3 else outline
        self.d.rounded_rectangle((x0, y0, x1, y1), radius=radius, fill=fill, outline=outline, width=width)

    def paste(self, rgb, x, y):
        self.im.paste(Image.fromarray(rgb), (int(x), int(y)))

    def array(self):
        return np.asarray(self.im).copy()


def chapter_chip(index, title):
    """The chapter title, top-left, on a dark chip so it reads over any photo. Returns (rgba, x, y)."""
    size = 34
    num = f"{index:02d}"
    tw = text_w(num, size, True) + 18 + text_w(title, size, True)
    c = Canvas(tw + 56, 68)
    c.rect(0, 0, c.w - 1, c.h - 1, CHIP + (232,), radius=14, outline=LINE + (255,), width=2)
    x = 28
    x += c.text(x, 34, num, size, ACCENT, True, anchor="lm") + 18
    c.text(x, 34, title, size, INK, True, anchor="lm")
    return c.array(), MARGIN, 40


def label_chip(s, size=26, colour=INK):
    """A small dark chip with one line, for naming what a picture is."""
    c = Canvas(text_w(s, size, True) + 36, size + 26)
    c.rect(0, 0, c.w - 1, c.h - 1, CHIP + (225,), radius=10, outline=LINE + (255,), width=1)
    c.text(18, c.h / 2, s, size, colour, True, anchor="lm")
    return c.array()


def _panel_rows(rows, title, w, h, extra, k):
    c = Canvas(w, h, PANEL + (255,))
    c.d.line((0, 0, 0, h), fill=LINE + (255,), width=2)
    x0, inner = 44, w - 44 - 40
    y = 150
    if title:
        c.text(x0, y, title.upper(), 26, SOFT, True)
        y += 54
    lab_size = max(26, int(28 * k))
    for row in rows:
        fig, lab = row[0], row[1]
        colour = row[2] if len(row) > 2 else ACCENT
        if fig is None:
            y += 10 * k
            c.text(x0, y, lab.upper(), 24, SOFT, True)
            y += 44 * k
            continue
        size = int(76 * k)
        while size > 40 and text_w(fig, size, True) > inner:
            size -= 4
        c.text(x0, y, fit(fig, size, inner, True), size, colour, True)
        y += size * 1.16
        y = c.lines(x0, y, wrap(lab, lab_size, inner, max_lines=3), lab_size, INK) + 26 * k
    if extra:
        y = extra(c, y)
    return c, y


def panel(rows, title=None, note=None, w=W - PANEL_X, h=H, extra=None):
    """The figures panel on the right: big numbers with a label each, an optional note at the foot.

    rows: (figure, label) or (figure, label, colour); a figure of None makes the label a small heading. extra(canvas, y)
    may draw more under the rows and returns the new y. Everything is kept inside the panel: figures shrink to its width,
    labels wrap, and the whole column is drawn smaller when it would run into the note or off the foot.
    """
    inner = w - 44 - 40
    note_lines = wrap(note, 25, inner, max_lines=7) if note else []
    note_h = len(note_lines) * 25 * 1.3
    for k in (1.0, 0.9, 0.8, 0.72, 0.64):
        c, y = _panel_rows(rows, title, w, h, extra, k)
        if y + 12 + note_h <= h - 48:
            break
    if note_lines and y + 12 + note_h <= h - 48:
        c.lines(44, max(y + 12, h - 56 - note_h), note_lines, 25, SOFT, gap=1.3)
    return c.array()


def blurred_ground(rgb, dim=0.32):
    """A photo turned into a quiet ground for a card: scaled to cover the frame, blurred and darkened."""
    im = Image.fromarray(rgb)
    s = max(W / im.width, H / im.height)
    im = im.resize((int(im.width * s) + 1, int(im.height * s) + 1), Image.BILINEAR)
    x, y = (im.width - W) // 2, (im.height - H) // 2
    im = im.crop((x, y, x + W, y + H)).filter(ImageFilter.GaussianBlur(18))
    a = np.asarray(im).astype(np.float32) * dim + np.array(BG, np.float32) * (1 - dim) * 0.9
    return np.clip(a, 0, 255).astype(np.uint8)