← Files AgentBrokoARCHIVED FILE
src/video_forge/draw.py
9.01 KB · Sep 30, 2026 · 23:15 UTC
"""Canvas: supersampled 2D primitives for the scene library.
Scenes draw in *design units* (a 1920x1080 coordinate space). The canvas renders
internally at `theme.ss`x that resolution and downsamples with LANCZOS on finish(),
which gives clean anti-aliased type, hairlines and rounded corners. Colours may be
RGB or RGBA; an optional `alpha` (0..1) multiplies the alpha for element fades.
"""
from PIL import Image, ImageChops, ImageDraw, ImageFilter
from .easing import clamp
class Canvas:
def __init__(self, theme, act):
self.theme = theme
self.act = act
self.ss = theme.ss
self.pal = theme.palette(act)
self.accent = theme.accent
self.W, self.H = theme.W, theme.H
self.pw, self.ph = self.s(self.W), self.s(self.H)
self.img = Image.new("RGB", (self.pw, self.ph), self.pal["bg"])
self.d = ImageDraw.Draw(self.img, "RGBA")
# -- scaling -----------------------------------------------------------
def s(self, v):
return int(round(v * self.ss))
def font(self, weight, size):
return self.theme.font(weight, size * self.ss)
def _col(self, fill, alpha=None):
if fill is None:
return None
if len(fill) == 3:
r, g, b, a = fill[0], fill[1], fill[2], 255
else:
r, g, b, a = fill
if alpha is not None:
a = int(round(a * clamp(alpha)))
return (r, g, b, a)
# -- text --------------------------------------------------------------
def _adv(self, s, font, track_px=0):
if not track_px:
return self.d.textlength(s, font=font)
return (sum(self.d.textlength(c, font=font) for c in s)
+ track_px * max(0, len(s) - 1))
def measure(self, s, weight, size, tracking=0):
"""Text advance width, in design units."""
f = self.font(weight, size)
return self._adv(s, f, tracking * self.ss) / self.ss
def fit_size(self, s, weight, start, max_w, min_size=22, tracking=0):
size = start
while size > min_size and self.measure(s, weight, size, tracking) > max_w:
size -= 2
return max(min_size, size)
def text(self, x, y, s, weight, size, fill, anchor="lm", tracking=0, alpha=None):
f = self.font(weight, size)
col = self._col(fill, alpha)
if col[3] <= 0:
return
px, py = self.s(x), self.s(y)
if not tracking:
self.d.text((px, py), s, font=f, fill=col, anchor=anchor)
return
track_px = tracking * self.ss
total = self._adv(s, f, track_px)
h = anchor[0] if anchor else "l"
v = anchor[1] if len(anchor) > 1 else "m"
if h == "m":
px -= total / 2
elif h == "r":
px -= total
cx = px
for c in s:
self.d.text((cx, py), c, font=f, fill=col, anchor="l" + v)
cx += self.d.textlength(c, font=f) + track_px
def text_center(self, cx, y, s, weight, size, fill, tracking=0, alpha=None,
anchor_v="m"):
self.text(cx, y, s, weight, size, fill, anchor="m" + anchor_v,
tracking=tracking, alpha=alpha)
# -- shapes ------------------------------------------------------------
def rrect(self, x, y, w, h, r, fill=None, outline=None, width=1, alpha=None):
box = [self.s(x), self.s(y), self.s(x + w), self.s(y + h)]
rad = self.s(r)
kw = {}
if fill is not None:
kw["fill"] = self._col(fill, alpha)
if outline is not None:
kw["outline"] = self._col(outline, alpha)
kw["width"] = max(1, self.s(width))
self.d.rounded_rectangle(box, radius=rad, **kw)
def pill(self, x, y, w, h, fill=None, outline=None, width=1, alpha=None):
self.rrect(x, y, w, h, h / 2, fill=fill, outline=outline, width=width, alpha=alpha)
def ellipse(self, cx, cy, rx, ry=None, fill=None, outline=None, width=1, alpha=None):
ry = rx if ry is None else ry
box = [self.s(cx - rx), self.s(cy - ry), self.s(cx + rx), self.s(cy + ry)]
kw = {}
if fill is not None:
kw["fill"] = self._col(fill, alpha)
if outline is not None:
kw["outline"] = self._col(outline, alpha)
kw["width"] = max(1, self.s(width))
self.d.ellipse(box, **kw)
def line(self, pts, fill, width=1, alpha=None):
p = [(self.s(x), self.s(y)) for x, y in pts]
self.d.line(p, fill=self._col(fill, alpha), width=max(1, self.s(width)),
joint="curve")
def rect(self, x, y, w, h, fill=None, outline=None, width=1, alpha=None):
box = [self.s(x), self.s(y), self.s(x + w), self.s(y + h)]
kw = {}
if fill is not None:
kw["fill"] = self._col(fill, alpha)
if outline is not None:
kw["outline"] = self._col(outline, alpha)
kw["width"] = max(1, self.s(width))
self.d.rectangle(box, **kw)
# -- colour fields -----------------------------------------------------
def grad_rrect(self, x, y, w, h, r, c0, c1, alpha=None, vertical=True):
"""Rounded rect filled with a linear c0 -> c1 gradient."""
pw, ph = self.s(w), self.s(h)
if pw <= 1 or ph <= 1:
return
a0, a1 = self._col(c0, alpha), self._col(c1, alpha)
if a0[3] <= 0 and a1[3] <= 0:
return
n = max(2, ph if vertical else pw)
strip = Image.new("RGBA", (1, n) if vertical else (n, 1))
px = strip.load()
for i in range(n):
t = i / (n - 1)
c = tuple(int(round(a0[k] + (a1[k] - a0[k]) * t)) for k in range(4))
if vertical:
px[0, i] = c
else:
px[i, 0] = c
grad = strip.resize((pw, ph), Image.Resampling.BILINEAR)
mask = Image.new("L", (pw, ph), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[0, 0, pw - 1, ph - 1], radius=self.s(r), fill=255)
grad.putalpha(ImageChops.multiply(grad.getchannel("A"), mask))
self.img.paste(grad, (self.s(x), self.s(y)), grad)
def glow(self, cx, cy, r, color, alpha=0.20):
"""Soft radial colour bloom — puts accent into otherwise empty background."""
pr = self.s(r)
if pr <= 1:
return
col = self._col(color, alpha)
if col[3] <= 0:
return
# Pad by 3 sigma so the blur reaches zero *inside* the tile; without this
# the tile boundary shows up as a hard rectangle over the background.
blur = pr * 0.42
pad = max(1, int(round(blur * 3)))
size = pr * 2 + pad * 2
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
ImageDraw.Draw(tile).ellipse(
[pad, pad, pad + pr * 2 - 1, pad + pr * 2 - 1], fill=col)
tile = tile.filter(ImageFilter.GaussianBlur(blur))
self.img.paste(tile, (self.s(cx) - pr - pad, self.s(cy) - pr - pad), tile)
def tint(self, color, t=0.5):
"""Blend a colour toward the card surface — a light, readable tinted fill.
Always blends toward `card` (white in both acts) rather than `card2`, so a
tinted surface stays light and dark ink stays legible on it regardless of act.
"""
base = self.pal["card"]
return tuple(int(round(base[i] + (color[i] - base[i]) * clamp(t)))
for i in range(3))
# -- elevation ---------------------------------------------------------
def drop_shadow(self, x, y, w, h, r, spread=26, alpha=0.16, dy=10):
"""Soft shadow rendered in a local tile, composited under a card."""
a = int(round(255 * clamp(alpha)))
if a <= 0:
return
pad = spread * 3
ox, oy = self.s(x - pad), self.s(y - pad + dy)
tw, th = self.s(w + 2 * pad), self.s(h + 2 * pad)
if tw <= 0 or th <= 0:
return
tile = Image.new("RGBA", (tw, th), (0, 0, 0, 0))
td = ImageDraw.Draw(tile)
td.rounded_rectangle([self.s(pad), self.s(pad), self.s(pad + w), self.s(pad + h)],
radius=self.s(r), fill=(0, 0, 0, a))
tile = tile.filter(ImageFilter.GaussianBlur(self.s(spread)))
# clip paste box to image bounds
self.img.paste(tile, (ox, oy), tile)
def card(self, x, y, w, h, r=18, fill=None, elevate=26, shadow_alpha=None,
alpha=1.0, outline=None, width=1):
if elevate and alpha > 0.02:
sa = shadow_alpha if shadow_alpha is not None else (
0.11 if self.act == "light" else 0.42)
self.drop_shadow(x, y, w, h, r, spread=elevate, alpha=sa * alpha)
self.rrect(x, y, w, h, r, fill=fill or self.pal["card"], alpha=alpha,
outline=outline, width=width)
def paste_layer(self, layer, x=0, y=0):
self.img.paste(layer, (self.s(x), self.s(y)), layer)
# -- finish ------------------------------------------------------------
def finish(self):
if (self.pw, self.ph) == (self.W, self.H):
return self.img
return self.img.resize((self.W, self.H), Image.Resampling.LANCZOS)
SHA-256: 376f602517366965e8d5527283e5a6a7686ec6008ab203c320af9ced0231c622