skill
Video Editing Skillpack
Public Made by Adomby adom
Adom's video editing kit: house skills for YouTube thumbnails (X-safe, brand-styled, with a builder script), titles and descriptions, plus the animated Adom logo sting (cut out on transparency, or blooming into the full-screen ADOM wordmark) with its sound effect, ready for Premiere.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
#!/usr/bin/env python3
"""Compose an Adom YouTube thumbnail (1280x720) in the house style.
Two ways to call it:
1. Quick mode (background frame + optional cutout + headline + orbital side):
make_thumbnail.py -o out.jpg --bg frame.png --cutout john.png \
--headline "AI PCBs in|Altium." --orbital left --check
Lines are separated by "|". The LAST line gets the teal-to-purple gradient.
--bg brand (default) draws the plain navy background (never a grid); a file path uses that
image as the right-hand visual (or full bleed with --bg-mode photo).
2. Spec mode (full control, layers drawn in order):
make_thumbnail.py spec.json -o out.jpg --check
See SKILL.md "Spec file" for every layer type: image, screen, headline,
arrow, bubble, badge, text.
Extras:
--check also write <out>-xcheck.png with the X / YouTube safe zones
and a 320x180 preview, and print any violations.
--check-only IMG overlay the safe zones on an existing thumbnail.
--arrow-assets DIR render the standard arrow PNGs into DIR.
Needs Pillow (and numpy). Fonts and the white orbital load from ./assets.
"""
import argparse, json, math, os, sys
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops
HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, "assets")
FONTS = os.path.join(ASSETS, "fonts")
W, H = 1280, 720
# ---------------------------------------------------------------- brand tokens
NAVY = (13, 17, 23) # #0d1117 background
SURFACE = (22, 27, 34) # #161b22
TEXT = (230, 237, 243) # #e6edf3 (headline white uses pure white, see SKILL.md)
TEAL = (0, 184, 177) # #00b8b1 primary
ACCENT = (0, 230, 220) # #00e6dc accent: arrows, kickers, starburst
PURPLE = (140, 107, 247) # #8c6bf7
# Headline gradient, measured from the 4 channel thumbnails (left to right across the line)
HEADLINE_GRADIENT = [(0.00, "#00b8b1"), (0.40, "#2fd0c3"), (0.70, "#5fb0e6"), (1.00, "#8c6bf7")]
# ---------------------------------------------------------------- safe zones (px at 1280x720)
X_ZONE = (0, 562, 768, 720) # X/Twitter title pill: left 60 %, bottom 22 %
YT_ZONE = (1088, 634, 1280, 720) # YouTube duration badge: right 15 %, bottom 12 %
EDGE_MARGIN = 24 # keep text and faces 24 px off every edge
# ---------------------------------------------------------------- orbital rule
ORBITAL_SIZE = 58 # px, measured on the channel thumbnails
ORBITAL_MARGIN_X = 30
ORBITAL_MARGIN_Y = 28
# ---------------------------------------------------------------- arrow spec (1280x720)
ARROW_COLOR = ACCENT # measured #06e3d9 after JPEG, i.e. brand accent #00e6dc
ARROW_STROKE = 11 # px
ARROW_HEAD_W = 4.0 # head base width = 4.0 x stroke (44 px)
ARROW_HEAD_L = 3.6 # head length = 3.6 x stroke (40 px)
ARROW_HEAD_ROUND = 0.15 # corner rounding = 0.15 x stroke (slightly soft corners)
ARROW_GLOW_SIGMA = 8 # px gaussian
ARROW_GLOW_OPACITY = 0.6
# ---------------------------------------------------------------- prompt bubble spec
BUBBLE_FONT_SIZE = 37 # Satoshi Bold, cap height about 26 px
BUBBLE_PAD_X = 46
BUBBLE_HEIGHT = 1.0 # x font size + 2 * pad_y
BUBBLE_PAD_Y = 31
BUBBLE_RADIUS = 20
BUBBLE_FILL = (11, 14, 19, 240) # #0b0e13 at 94 %
BUBBLE_BORDER = (230, 237, 243, 90) # light border, 2 px, 35 %
BUBBLE_BORDER_W = 2
BUBBLE_ROTATE = 3.0 # degrees counter-clockwise (right end higher)
SS = 4 # supersampling factor for vector shapes
# ================================================================ helpers
def hexrgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def font(role, size):
"""role: display (Familjen Grotesk Bold) | body (Satoshi Medium) | bodybold (Satoshi Bold)."""
if role == "display":
f = ImageFont.truetype(os.path.join(FONTS, "FamiljenGrotesk-Variable.ttf"), size)
f.set_variation_by_name("Bold")
else:
f = ImageFont.truetype(os.path.join(FONTS, "Satoshi-Variable.ttf"), size)
f.set_variation_by_name("Bold" if role == "bodybold" else "Medium")
return f
def lerp_stops(stops, t):
stops = [(p, hexrgb(c) if isinstance(c, str) else c) for p, c in stops]
for (p0, c0), (p1, c1) in zip(stops, stops[1:]):
if t <= p1:
k = 0 if p1 == p0 else (t - p0) / (p1 - p0)
return tuple(int(round(a + (b - a) * k)) for a, b in zip(c0, c1))
return stops[-1][1]
def h_gradient(w, h, stops):
row = Image.new("RGB", (max(w, 1), 1))
for x in range(max(w, 1)):
row.putpixel((x, 0), lerp_stops(stops, x / max(w - 1, 1)))
return row.resize((max(w, 1), max(h, 1)))
def radial(size, center, radius, color, opacity):
"""RGBA layer with a soft radial glow."""
w, h = size
small = Image.new("L", (w // 8, h // 8), 0)
d = ImageDraw.Draw(small)
cx, cy, r = center[0] / 8, center[1] / 8, radius / 8
steps = 40
for i in range(steps, 0, -1):
rr = r * i / steps
v = int(255 * opacity * (1 - i / steps) ** 1.6)
d.ellipse((cx - rr, cy - rr, cx + rr, cy + rr), fill=v)
mask = small.resize((w, h), Image.BICUBIC).filter(ImageFilter.GaussianBlur(6))
layer = Image.new("RGBA", (w, h), color + (0,))
layer.putalpha(mask)
return layer
def alpha_mul(im, k):
im = im.copy()
a = im.getchannel("A").point(lambda v: int(v * k))
im.putalpha(a)
return im
def shadow_of(rgba, blur=18, opacity=0.5, offset=(0, 10), color=(0, 0, 0)):
a = rgba.getchannel("A")
pad = blur * 3
big = Image.new("L", (rgba.width + 2 * pad, rgba.height + 2 * pad), 0)
big.paste(a, (pad, pad))
big = big.filter(ImageFilter.GaussianBlur(blur)).point(lambda v: int(v * opacity))
sh = Image.new("RGBA", big.size, color + (0,))
sh.putalpha(big)
return sh, (offset[0] - pad, offset[1] - pad)
def glow_of(rgba, sigma, opacity, color):
return shadow_of(rgba, blur=sigma, opacity=opacity, offset=(0, 0), color=color)
# ================================================================ background
def brand_background(glow_left=0.55, glow_right=0.5):
"""Plain navy base, teal glow bottom-left, purple glow bottom-right. No grid, ever (Anastasia, 2026-10-21)."""
bg = Image.new("RGBA", (W, H), NAVY + (255,))
# slight vertical lift toward the top (#0c0e1a measured top-left)
top = Image.new("RGBA", (W, H), (12, 14, 26, 0))
m = Image.linear_gradient("L").resize((W, H)).point(lambda v: int((255 - v) * 0.5))
top.putalpha(m)
bg = Image.alpha_composite(bg, top)
bg = Image.alpha_composite(bg, radial((W, H), (-60, 800), 700, TEAL, glow_left))
bg = Image.alpha_composite(bg, radial((W, H), (1340, 820), 640, PURPLE, glow_right))
return bg
def photo_background(path, crop=None, blur=0, darken=0.35, scrim="left", scrim_strength=0.85):
im = Image.open(path).convert("RGB")
if crop:
im = im.crop(tuple(crop))
# cover-fit
s = max(W / im.width, H / im.height)
im = im.resize((math.ceil(im.width * s), math.ceil(im.height * s)), Image.LANCZOS)
l, t = (im.width - W) // 2, (im.height - H) // 2
im = im.crop((l, t, l + W, t + H))
if blur:
im = im.filter(ImageFilter.GaussianBlur(blur))
im = im.convert("RGBA")
if darken:
im = Image.alpha_composite(im, Image.new("RGBA", (W, H), NAVY + (int(255 * darken),)))
if scrim in ("left", "right"):
g = Image.linear_gradient("L").rotate(90 if scrim == "left" else -90, expand=True).resize((W, H))
g = g.point(lambda v: int(v * scrim_strength))
sc = Image.new("RGBA", (W, H), NAVY + (0,))
sc.putalpha(g)
im = Image.alpha_composite(im, sc)
im = Image.alpha_composite(im, radial((W, H), (-60, 800), 700, TEAL, 0.45))
im = Image.alpha_composite(im, radial((W, H), (1340, 820), 640, PURPLE, 0.4))
return im
# ================================================================ layers
def place_image(canvas, L):
im = Image.open(L["path"]).convert("RGBA")
if L.get("crop"):
im = im.crop(tuple(L["crop"]))
if L.get("flip"):
im = im.transpose(Image.FLIP_LEFT_RIGHT)
x, y, w = L["box"][:3]
h = round(im.height * w / im.width)
im = im.resize((round(w), h), Image.LANCZOS)
if L.get("rotate"):
im = im.rotate(L["rotate"], resample=Image.BICUBIC, expand=True)
if L.get("fade_bottom"):
fb = int(L["fade_bottom"])
a = im.getchannel("A")
g = Image.linear_gradient("L").resize((im.width, fb)).point(lambda v: 255 - v)
mask = Image.new("L", im.size, 255)
mask.paste(g, (0, im.height - fb))
im.putalpha(ImageChops.multiply(a, mask))
if L.get("glow"):
gl, off = glow_of(im, 14, 0.45, hexrgb(L["glow"]) if isinstance(L["glow"], str) else ACCENT)
canvas.alpha_composite(gl, (int(x + off[0]), int(y + off[1])))
if L.get("shadow", True):
sh, off = shadow_of(im, blur=L.get("shadow_blur", 18), opacity=L.get("shadow_opacity", 0.55))
canvas.alpha_composite(sh, (int(x + off[0]), int(y + off[1])))
canvas.alpha_composite(im, (int(x), int(y)))
return (x, y, x + im.width, y + h)
def _persp_coeffs(dst, src):
import numpy as np
A, B = [], []
for (x, y), (u, v) in zip(dst, src):
A.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); B.append(u)
A.append([0, 0, 0, x, y, 1, -v * x, -v * y]); B.append(v)
return list(np.linalg.solve(np.array(A, float), np.array(B, float)))
def place_screen(canvas, L):
"""App screenshot mapped onto a quad (tl, tr, br, bl), rounded, bordered, optional teal edge glow."""
im = Image.open(L["path"]).convert("RGBA")
if L.get("crop"):
im = im.crop(tuple(L["crop"]))
r = int(L.get("radius", 14) * im.width / 900)
mask = Image.new("L", im.size, 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, im.width - 1, im.height - 1), r, fill=255)
im.putalpha(mask)
bd = ImageDraw.Draw(im)
bd.rounded_rectangle((0, 0, im.width - 1, im.height - 1), r, outline=(48, 54, 61, 255),
width=max(2, im.width // 400))
if L.get("dim"):
dim = Image.new("RGBA", im.size, NAVY + (int(255 * L["dim"]),))
dim.putalpha(ImageChops.multiply(dim.getchannel("A"), mask))
im = Image.alpha_composite(im, dim)
quad = [tuple(p) for p in L["quad"]]
src = [(0, 0), (im.width, 0), (im.width, im.height), (0, im.height)]
out = im.transform((W, H), Image.PERSPECTIVE, _persp_coeffs(quad, src), Image.BICUBIC)
if L.get("glow", True):
gl, off = glow_of(out, 16, 0.55, ACCENT)
canvas.alpha_composite(gl, off)
sh, off = shadow_of(out, blur=24, opacity=0.6, offset=(0, 14))
canvas.alpha_composite(sh, off)
canvas.alpha_composite(out)
xs, ys = [p[0] for p in quad], [p[1] for p in quad]
return (min(xs), min(ys), max(xs), max(ys))
def draw_headline(canvas, L):
"""Big Familjen Grotesk Bold headline. Last line gradient. Optional kicker above and sub below."""
lines = L["lines"]
size = L.get("size", 120)
maxw = L.get("max_width")
f = font("display", size)
if maxw:
while size > 60 and max(f.getbbox(t)[2] - f.getbbox(t)[0] for t in lines) > maxw:
size -= 2
f = font("display", size)
pitch = round(size * L.get("leading", 0.96))
x0, y0 = L.get("x", 52), L.get("y", 220) # y = top of the first line's cap height
align = L.get("align", "left")
cap = round(size * 0.65) # Familjen cap height = 780/1200 em
asc = f.getmetrics()[0]
boxes = []
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ky = y0
if L.get("kicker"):
kf = font("display", L.get("kicker_size", 40))
txt = L["kicker"].upper()
track = round(L.get("kicker_size", 40) * 0.14)
kx = x0
widths = [kf.getlength(ch) + track for ch in txt]
total = sum(widths) - track
if align == "right":
kx = x0 - total
d = ImageDraw.Draw(layer)
kc = round(L.get("kicker_size", 40) * 0.65)
kyy = y0 - kc - L.get("kicker_gap", 34)
cx = kx
for ch, wch in zip(txt, widths):
d.text((cx, kyy + kc), ch, font=kf, fill=ACCENT + (255,), anchor="ls")
cx += wch
boxes.append((kx, kyy, kx + total, kyy + kc))
for i, t in enumerate(lines):
base = y0 + cap + i * pitch
bb = f.getbbox(t, anchor="ls")
tw = bb[2] - bb[0]
lx = x0 - tw if align == "right" else x0
last = i == len(lines) - 1
m = Image.new("L", (W, H), 0)
ImageDraw.Draw(m).text((lx - bb[0], base), t, font=f, fill=255, anchor="ls")
tb = m.getbbox()
if not tb:
continue
if last and L.get("gradient", True):
g = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gw = tb[2] - tb[0]
g.paste(h_gradient(gw, tb[3] - tb[1], L.get("stops", HEADLINE_GRADIENT)), (tb[0], tb[1]))
g.putalpha(m)
else:
g = Image.new("RGBA", (W, H), (255, 255, 255, 0))
g.putalpha(m)
layer = Image.alpha_composite(layer, g)
boxes.append(tb)
if L.get("sub"):
sf = font("display", L.get("sub_size", 46))
parts = L["sub"] # list of [text, "white"|"teal"]
if isinstance(parts, str):
parts = [[parts, "white"]]
sy = y0 + cap + (len(lines) - 1) * pitch + L.get("sub_gap", 64)
sx = x0
d = ImageDraw.Draw(layer)
total = sum(sf.getlength(p[0]) for p in parts)
if align == "right":
sx = x0 - total
start = sx
for txt, col in parts:
d.text((sx, sy), txt, font=sf, fill=(ACCENT if col == "teal" else (255, 255, 255)) + (255,), anchor="ls")
sx += sf.getlength(txt)
boxes.append((start, sy - round(L.get("sub_size", 46) * 0.72), sx, sy + round(L.get("sub_size", 46) * 0.2)))
if L.get("shadow", True):
sh, off = shadow_of(layer, blur=14, opacity=L.get("shadow_opacity", 0.45), offset=(0, 4))
canvas.alpha_composite(sh, off)
canvas.alpha_composite(layer)
bx = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes))
return bx
# ---------------------------------------------------------------- arrow
def _bezier(p0, p1, bend, n=200, control=None):
(x0, y0), (x1, y1) = p0, p1
dx, dy = x1 - x0, y1 - y0
length = math.hypot(dx, dy)
nx, ny = -dy / length, dx / length # screen-space normal, right of travel
cx, cy = (x0 + x1) / 2 + nx * bend * length, (y0 + y1) / 2 + ny * bend * length
if control is not None: # explicit control point (e.g. a hook)
cx, cy = control
pts = []
for i in range(n + 1):
t = i / n
pts.append(((1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
(1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1))
return pts
def arrow_layer(size, start, end, bend=0.25, scale=1.0, color=ARROW_COLOR, glow=True, control=None):
"""Return an RGBA layer (size) with the house arrow from start to end.
bend: signed curvature, control point offset as a fraction of the chord length
(0 = straight; 0.2 to 0.35 is the house range). Positive bulges to the
RIGHT of the direction of travel: a left-to-right arrow with bend +0.3 sags
downward like the KiCad thumbnail; a top-to-bottom arrow with bend -0.3
bulges right like the radio thumbnail's hook.
scale: 1.0 = 11 px stroke at 1280x720.
control: optional explicit quadratic control point; overrides bend. For the radio-style
hook use control = (end_x, start_y): it leaves horizontally and lands vertically.
"""
w, h = size
sw = ARROW_STROKE * scale
hl, hw = ARROW_HEAD_L * sw, ARROW_HEAD_W * sw
pts = _bezier(start, end, bend, control=control)
# end tangent from the last segment
ex, ey = end
k = len(pts) - 1
while k > 0 and math.hypot(ex - pts[k][0], ey - pts[k][1]) < hl * 0.9:
k -= 1
tx, ty = ex - pts[k][0], ey - pts[k][1]
tl = math.hypot(tx, ty) or 1
ux, uy = tx / tl, ty / tl
bx, by = ex - ux * hl, ey - uy * hl # head base center
stroke_pts = pts[:k + 1] + [(bx + ux * sw * 0.6, by + uy * sw * 0.6)]
big = Image.new("L", (w * SS, h * SS), 0)
d = ImageDraw.Draw(big)
r = sw * SS / 2
sp = [(x * SS, y * SS) for x, y in stroke_pts]
# dense discs along the path: uniform width, round caps, smooth edges
dense = []
for (xa, ya), (xb, yb) in zip(sp, sp[1:]):
n = max(1, int(math.hypot(xb - xa, yb - ya) / (r * 0.25)))
dense += [(xa + (xb - xa) * i / n, ya + (yb - ya) * i / n) for i in range(n)]
dense.append(sp[-1])
for x, y in dense:
d.ellipse((x - r, y - r, x + r, y + r), fill=255)
px, py = -uy, ux
tri = [(ex, ey), (bx + px * hw / 2, by + py * hw / 2), (bx - px * hw / 2, by - py * hw / 2)]
# rounded corners: shrink the triangle toward its centroid, draw, then dilate by the rounding radius
rr = ARROW_HEAD_ROUND * sw
cxm, cym = sum(p[0] for p in tri) / 3, sum(p[1] for p in tri) / 3
tri2 = []
for x, y in tri:
vx, vy = x - cxm, y - cym
vl = math.hypot(vx, vy)
s = (vl - rr * 2.0) / vl
tri2.append(((cxm + vx * s) * SS, (cym + vy * s) * SS))
head = Image.new("L", big.size, 0)
ImageDraw.Draw(head).polygon(tri2, fill=255)
head = head.filter(ImageFilter.MaxFilter(int(rr * SS * 2) | 1))
big = ImageChops.lighter(big, head)
mask = big.resize((w, h), Image.LANCZOS)
out = Image.new("RGBA", (w, h), color + (0,))
out.putalpha(mask)
if glow:
g = mask.filter(ImageFilter.GaussianBlur(ARROW_GLOW_SIGMA * scale)).point(lambda v: int(min(255, v * ARROW_GLOW_OPACITY * 1.6)))
gl = Image.new("RGBA", (w, h), color + (0,))
gl.putalpha(g)
out = Image.alpha_composite(gl, out)
return out
def draw_arrow(canvas, L):
lay = arrow_layer((W, H), tuple(L["from"]), tuple(L["to"]), L.get("bend", 0.25), L.get("scale", 1.0),
control=tuple(L["control"]) if L.get("control") else None)
canvas.alpha_composite(lay)
return lay.getchannel("A").point(lambda v: 255 if v > 60 else 0).getbbox()
# ---------------------------------------------------------------- prompt bubble
def bubble_layer(text, scale=1.0, rotate=BUBBLE_ROTATE):
"""Dark pill with a thin light border and the prompt in curly quotes. Returns RGBA (with shadow)."""
t = text.strip().strip('"“”')
if not t.endswith((".", "?")):
t += "."
t = "“" + t + "”"
fs = round(BUBBLE_FONT_SIZE * scale)
f = font("bodybold", fs * SS)
tw = f.getlength(t)
padx, pady = BUBBLE_PAD_X * scale * SS, BUBBLE_PAD_Y * scale * SS
bw = int(tw + 2 * padx)
bh = int(fs * SS * BUBBLE_HEIGHT + 2 * pady)
m = int(40 * scale * SS)
im = Image.new("RGBA", (bw + 2 * m, bh + 2 * m), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
box = (m, m, m + bw, m + bh)
rad = BUBBLE_RADIUS * scale * SS
d.rounded_rectangle(box, rad, fill=BUBBLE_FILL)
d.rounded_rectangle(box, rad, outline=BUBBLE_BORDER, width=int(BUBBLE_BORDER_W * scale * SS))
# optical vertical centre on cap height
cap = fs * SS * 0.70
d.text((m + padx, m + bh / 2 + cap / 2), t, font=f, fill=(255, 255, 255, 255), anchor="ls")
im = im.resize((im.width // SS, im.height // SS), Image.LANCZOS)
if rotate:
im = im.rotate(rotate, resample=Image.BICUBIC, expand=True)
sh, off = shadow_of(im, blur=int(16 * scale), opacity=0.55, offset=(0, int(6 * scale)))
base = Image.new("RGBA", sh.size, (0, 0, 0, 0))
base.alpha_composite(sh)
base.alpha_composite(im, (-off[0], -off[1]))
# return the layer plus the position of the pill's own top-left inside it
return base, (-off[0], -off[1]), im.size
def draw_bubble(canvas, L):
lay, inner, isz = bubble_layer(L["text"], L.get("scale", 1.0), L.get("rotate", BUBBLE_ROTATE))
cx, cy = L["center"]
x = int(cx - isz[0] / 2 - inner[0])
y = int(cy - isz[1] / 2 - inner[1])
canvas.alpha_composite(lay, (x, y)) if x >= 0 and y >= 0 else _paste_clip(canvas, lay, x, y)
m = int(36 * L.get("scale", 1.0))
return (cx - isz[0] / 2 + m, cy - isz[1] / 2 + m, cx + isz[0] / 2 - m, cy + isz[1] / 2 - m)
def _paste_clip(canvas, lay, x, y):
tmp = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
tmp.paste(lay, (x, y))
canvas.alpha_composite(tmp)
# ---------------------------------------------------------------- starburst badge
def draw_badge(canvas, L):
cx, cy = L["center"]
r = L.get("r", 130)
pts_n = L.get("points", 22)
big = Image.new("RGBA", (W * 2, H * 2), (0, 0, 0, 0))
d = ImageDraw.Draw(big)
poly = []
for i in range(pts_n * 2):
a = math.pi * i / pts_n + math.radians(L.get("rotate", -12))
rr = r if i % 2 == 0 else r * 0.86
poly.append(((cx + rr * math.cos(a)) * 2, (cy + rr * math.sin(a)) * 2))
d.polygon(poly, fill=ACCENT + (255,))
lay = big.resize((W, H), Image.LANCZOS)
sh, off = shadow_of(lay, blur=16, opacity=0.5, offset=(0, 8))
canvas.alpha_composite(sh, off)
canvas.alpha_composite(lay)
lines = L.get("lines", ["1st", "EVER"])
txt = Image.new("RGBA", (W, H), (0, 0, 0, 0))
td = ImageDraw.Draw(txt)
f1 = font("display", int(r * 0.78))
f2 = font("display", int(r * 0.30))
td.text((cx, cy + r * 0.12), lines[0], font=f1, fill=NAVY + (255,), anchor="ms")
if len(lines) > 1:
td.text((cx, cy + r * 0.52), lines[1], font=f2, fill=NAVY + (255,), anchor="ms")
txt = txt.rotate(L.get("text_rotate", 12), center=(cx, cy), resample=Image.BICUBIC)
canvas.alpha_composite(txt)
return (cx - r, cy - r, cx + r, cy + r)
def draw_text(canvas, L):
f = font(L.get("font", "display"), L.get("size", 40))
d = ImageDraw.Draw(canvas)
col = L.get("color", "#ffffff")
d.text(tuple(L["at"]), L["text"], font=f, fill=hexrgb(col) + (255,), anchor=L.get("anchor", "ls"))
return d.textbbox(tuple(L["at"]), L["text"], font=f, anchor=L.get("anchor", "ls"))
def draw_orbital(canvas, side):
o = Image.open(os.path.join(ASSETS, "adom-orbital-white-512.png")).convert("RGBA")
o = o.resize((ORBITAL_SIZE, ORBITAL_SIZE), Image.LANCZOS)
x = ORBITAL_MARGIN_X if side == "left" else W - ORBITAL_MARGIN_X - ORBITAL_SIZE
sh, off = shadow_of(o, blur=8, opacity=0.35, offset=(0, 2))
canvas.alpha_composite(sh, (x + off[0], ORBITAL_MARGIN_Y + off[1]))
canvas.alpha_composite(o, (x, ORBITAL_MARGIN_Y))
return (x, ORBITAL_MARGIN_Y, x + ORBITAL_SIZE, ORBITAL_MARGIN_Y + ORBITAL_SIZE)
DRAW = {"image": place_image, "screen": place_screen, "headline": draw_headline,
"arrow": draw_arrow, "bubble": draw_bubble, "badge": draw_badge, "text": draw_text}
CHECKED = {"headline", "bubble", "badge", "text"} # always must stay out of the zones
# ================================================================ compose / check
def compose(spec):
bg = spec.get("background", {"type": "brand"})
if bg.get("type", "brand") == "brand":
canvas = brand_background(bg.get("glow_left", 0.55), bg.get("glow_right", 0.5))
else:
canvas = photo_background(bg["path"], bg.get("crop"), bg.get("blur", 0), bg.get("darken", 0.35),
bg.get("scrim", "left"), bg.get("scrim_strength", 0.85))
important = []
for L in spec.get("layers", []):
box = DRAW[L["type"]](canvas, L)
if L["type"] in CHECKED or L.get("important"):
important.append((L.get("name", L["type"]), L.get("important_box", box)))
side = spec.get("orbital", "left")
obox = draw_orbital(canvas, side)
important.append(("orbital", obox))
return canvas.convert("RGB"), important
def _intersects(a, b):
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def violations(important):
out = []
for name, b in important:
if b is None:
continue
if _intersects(b, X_ZONE):
out.append(f"{name} {tuple(int(v) for v in b)} overlaps the X title zone {X_ZONE}")
if _intersects(b, YT_ZONE):
out.append(f"{name} {tuple(int(v) for v in b)} overlaps the YouTube duration zone {YT_ZONE}")
if name != "orbital" and (b[0] < EDGE_MARGIN or b[1] < EDGE_MARGIN or b[2] > W - EDGE_MARGIN or b[3] > H - EDGE_MARGIN):
out.append(f"{name} {tuple(int(v) for v in b)} is within {EDGE_MARGIN} px of the edge")
return out
def check_overlay(img, important=None):
"""1280x720 thumbnail + zone overlay, with a 320x180 preview strip underneath."""
img = img.convert("RGBA").resize((W, H))
ov = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
lab = font("bodybold", 22)
# X zone: red hatch
d.rectangle(X_ZONE, fill=(255, 40, 60, 70), outline=(255, 40, 60, 255), width=3)
for k in range(-H, W, 28):
d.line((X_ZONE[0] + k, X_ZONE[3], X_ZONE[0] + k + (X_ZONE[3] - X_ZONE[1]), X_ZONE[1]), fill=(255, 40, 60, 90), width=2)
ov2 = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ov2.paste(ov.crop(X_ZONE), X_ZONE[:2])
ov = ov2
d = ImageDraw.Draw(ov)
d.rectangle(X_ZONE, outline=(255, 40, 60, 255), width=3)
d.text((X_ZONE[0] + 12, X_ZONE[1] + 10), "X title pill: keep clear (0,562)-(768,720)", font=lab, fill=(255, 255, 255, 255))
d.rectangle(YT_ZONE, fill=(255, 160, 0, 90), outline=(255, 160, 0, 255), width=3)
d.text((YT_ZONE[0] + 10, YT_ZONE[1] + 10), "YT 12:34", font=lab, fill=(255, 255, 255, 255))
d.rectangle((EDGE_MARGIN, EDGE_MARGIN, W - EDGE_MARGIN, H - EDGE_MARGIN), outline=(255, 255, 255, 60), width=1)
for xo in (ORBITAL_MARGIN_X, W - ORBITAL_MARGIN_X - ORBITAL_SIZE):
d.rectangle((xo, ORBITAL_MARGIN_Y, xo + ORBITAL_SIZE, ORBITAL_MARGIN_Y + ORBITAL_SIZE), outline=(0, 230, 220, 200), width=2)
for name, b in (important or []):
if b:
d.rectangle(tuple(int(v) for v in b), outline=(255, 255, 0, 200), width=2)
d.text((b[0] + 4, b[1] + 2), name, font=font("bodybold", 16), fill=(255, 255, 0, 255))
top = Image.alpha_composite(img, ov).convert("RGB")
sheet = Image.new("RGB", (W, H + 200), (8, 10, 14))
sheet.paste(top, (0, 0))
small = img.convert("RGB").resize((320, 180), Image.LANCZOS)
sheet.paste(small, (10, H + 10))
# simulated X card pill over a second small copy
xs = small.copy()
xd = ImageDraw.Draw(xs, "RGBA")
xd.rounded_rectangle((8, 180 - 34, 8 + 190, 180 - 10), 6, fill=(0, 0, 0, 170))
xd.text((16, 180 - 30), "youtube.com Video title", font=font("body", 13), fill=(255, 255, 255, 255))
sheet.paste(xs, (350, H + 10))
d2 = ImageDraw.Draw(sheet)
d2.text((700, H + 30), "Left: 320x180 (search size).", font=font("body", 22), fill=(230, 237, 243))
d2.text((700, H + 62), "Middle: X card with its title pill.", font=font("body", 22), fill=(230, 237, 243))
d2.text((700, H + 94), "Red: X pill zone. Orange: YT duration.", font=font("body", 22), fill=(230, 237, 243))
d2.text((700, H + 126), "Yellow: checked elements. Teal: orbital slots.", font=font("body", 22), fill=(230, 237, 243))
return sheet
def save_thumb(img, path):
if path.lower().endswith((".jpg", ".jpeg")):
q = 92
while True:
img.save(path, quality=q, optimize=True, progressive=True)
if os.path.getsize(path) < 2_000_000 or q <= 70:
break
q -= 4
else:
img.save(path, optimize=True)
return os.path.getsize(path)
def write_arrow_assets(outdir):
os.makedirs(outdir, exist_ok=True)
presets = {
# name: canvas size, start, end, bend (KiCad-style swoosh, radio-style hook, straight, short)
"arrow-swoosh-right": ((360, 170), (30, 40), (320, 60), 0.30),
"arrow-hook-down": ((200, 320), (30, 40), (150, 290), (150, 40)),
"arrow-curve-left": ((360, 170), (330, 40), (40, 60), -0.30),
"arrow-straight-right": ((300, 120), (30, 60), (270, 60), 0.0),
}
for name, (size, s, e, b) in presets.items():
lay = arrow_layer(size, s, e, control=b) if isinstance(b, tuple) else arrow_layer(size, s, e, b)
lay.save(os.path.join(outdir, name + ".png"), optimize=True)
print("wrote", os.path.join(outdir, name + ".png"))
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec", nargs="?", help="JSON spec file (spec mode)")
ap.add_argument("-o", "--out", help="output .jpg or .png")
ap.add_argument("--bg", default="brand", help="'brand' or a background frame image")
ap.add_argument("--bg-mode", choices=["visual", "photo"], default="visual",
help="visual: frame as a tilted screen on the right; photo: full bleed with a scrim")
ap.add_argument("--cutout", help="transparent PNG of a presenter or board, placed right")
ap.add_argument("--headline", help="lines separated by |, last line gradient, end with a period")
ap.add_argument("--kicker", help="small tracked teal caps above the headline")
ap.add_argument("--orbital", choices=["left", "right"], default="left")
ap.add_argument("--check", action="store_true", help="also write <out>-xcheck.png")
ap.add_argument("--check-only", help="write the zone overlay for an existing image")
ap.add_argument("--arrow-assets", help="render the standard arrow PNGs into this folder")
a = ap.parse_args()
if a.arrow_assets:
write_arrow_assets(a.arrow_assets)
return
if a.check_only:
out = a.out or os.path.splitext(a.check_only)[0] + "-xcheck.png"
check_overlay(Image.open(a.check_only)).save(out)
print("wrote", out)
return
if a.spec:
spec = json.load(open(a.spec))
base = os.path.dirname(os.path.abspath(a.spec))
for L in spec.get("layers", []):
if "path" in L and not os.path.isabs(L["path"]):
L["path"] = os.path.join(base, L["path"])
bgs = spec.get("background", {})
if "path" in bgs and not os.path.isabs(bgs["path"]):
bgs["path"] = os.path.join(base, bgs["path"])
else:
if not (a.headline and a.out):
ap.error("quick mode needs --headline and -o")
left = a.orbital == "left"
spec = {"orbital": a.orbital, "layers": []}
if a.bg != "brand" and a.bg_mode == "photo":
spec["background"] = {"type": "photo", "path": a.bg, "scrim": "left" if left else "right"}
else:
spec["background"] = {"type": "brand"}
if a.bg != "brand":
quad = [[690, 90], [1300, 60], [1300, 560], [690, 520]] if left else \
[[-20, 60], [590, 90], [590, 520], [-20, 560]]
spec["layers"].append({"type": "screen", "path": a.bg, "quad": quad, "dim": 0.15})
if a.cutout:
spec["layers"].append({"type": "image", "path": a.cutout,
"box": [700, 140, 560] if left else [20, 140, 560]})
lines = a.headline.split("|")
spec["layers"].append({"type": "headline", "lines": lines, "kicker": a.kicker,
"x": 52 if left else 1228, "align": "left" if left else "right",
"y": 250 if len(lines) <= 2 else 200, "size": 120, "max_width": 620})
img, important = compose(spec)
size = save_thumb(img, a.out)
print(f"wrote {a.out} ({size/1e6:.2f} MB, {img.size[0]}x{img.size[1]})")
probs = violations(important)
for p in probs:
print("SAFE-ZONE:", p)
if not probs:
print("safe zones: clear")
if a.check:
xc = os.path.splitext(a.out)[0] + "-xcheck.png"
check_overlay(img, important).save(xc, optimize=True)
print("wrote", xc)
if __name__ == "__main__":
main()