2026-04-29 00:58:38 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-05-05 19:02:47 -04:00
|
|
|
"""Frame Nyx screenshots with the brand mint-led four-corner gradient.
|
2026-04-29 00:58:38 -04:00
|
|
|
|
|
|
|
|
Reads a list of PNG paths from argv (or all PNGs under
|
|
|
|
|
assets/screenshots/ if no args) and overwrites each with a framed
|
|
|
|
|
version: inner screenshot with rounded corners, centered on a
|
2026-05-05 19:02:47 -04:00
|
|
|
four-corner mint-led gradient (TL #72f3d7, TR #ff6aa2,
|
|
|
|
|
BL #f8c56b, BR #4cc9ff).
|
2026-04-29 00:58:38 -04:00
|
|
|
|
|
|
|
|
Two framing modes:
|
|
|
|
|
- default inner is resampled to 1600x992, outer is 1800x1113.
|
|
|
|
|
Used for serve-* PNGs whose source is 1440x900.
|
|
|
|
|
- --natural inner is kept at its native size, outer grows to
|
|
|
|
|
match (inner + 100/60/100/61 padding). Used for
|
|
|
|
|
CLI captures whose height varies per command.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
python3 scripts/frame-screenshots.py path/to/foo.png ...
|
|
|
|
|
python3 scripts/frame-screenshots.py --natural path/to/cli.png ...
|
|
|
|
|
python3 scripts/frame-screenshots.py # frames the default set
|
|
|
|
|
|
|
|
|
|
Framing is not idempotent — re-framing an already-framed image will
|
|
|
|
|
re-pad it, so callers are expected to keep raw captures separately or
|
|
|
|
|
re-capture before re-framing.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
|
|
|
|
# Frame geometry (matches existing docs/serve-*.png files).
|
|
|
|
|
OUTER_W, OUTER_H = 1800, 1113
|
|
|
|
|
PAD_L, PAD_T = 100, 60
|
|
|
|
|
INNER_W, INNER_H = 1600, 992
|
|
|
|
|
PAD_R = OUTER_W - INNER_W - PAD_L # 100
|
|
|
|
|
PAD_B = OUTER_H - INNER_H - PAD_T # 61
|
|
|
|
|
CORNER_RADIUS = 12
|
|
|
|
|
|
2026-05-05 19:02:47 -04:00
|
|
|
# Four-corner bilinear gradient. The primary brand accent anchors the
|
|
|
|
|
# frame, with distinct warm/cool corners for richer screenshot depth.
|
|
|
|
|
GRAD_TL = (114, 243, 215) # #72f3d7
|
|
|
|
|
GRAD_TR = (255, 106, 162) # #ff6aa2
|
|
|
|
|
GRAD_BL = (248, 197, 107) # #f8c56b
|
|
|
|
|
GRAD_BR = ( 76, 201, 255) # #4cc9ff
|
2026-04-29 00:58:38 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_gradient(w: int, h: int) -> Image.Image:
|
|
|
|
|
"""Bilinear gradient between the four GRAD_* corners.
|
|
|
|
|
|
|
|
|
|
Implemented row-by-row with PIL's linear-interpolation paste so a
|
|
|
|
|
1800x1113 canvas builds in a few hundred ms (vs ~10s for a pure-
|
|
|
|
|
Python pixel loop).
|
|
|
|
|
"""
|
|
|
|
|
# Top edge: TL → TR
|
|
|
|
|
top_row = Image.new("RGB", (w, 1))
|
|
|
|
|
top_pixels = top_row.load()
|
|
|
|
|
for x in range(w):
|
|
|
|
|
t = x / (w - 1) if w > 1 else 0.0
|
|
|
|
|
top_pixels[x, 0] = (
|
|
|
|
|
int(GRAD_TL[0] + (GRAD_TR[0] - GRAD_TL[0]) * t),
|
|
|
|
|
int(GRAD_TL[1] + (GRAD_TR[1] - GRAD_TL[1]) * t),
|
|
|
|
|
int(GRAD_TL[2] + (GRAD_TR[2] - GRAD_TL[2]) * t),
|
|
|
|
|
)
|
|
|
|
|
# Bottom edge: BL → BR
|
|
|
|
|
bot_row = Image.new("RGB", (w, 1))
|
|
|
|
|
bot_pixels = bot_row.load()
|
|
|
|
|
for x in range(w):
|
|
|
|
|
t = x / (w - 1) if w > 1 else 0.0
|
|
|
|
|
bot_pixels[x, 0] = (
|
|
|
|
|
int(GRAD_BL[0] + (GRAD_BR[0] - GRAD_BL[0]) * t),
|
|
|
|
|
int(GRAD_BL[1] + (GRAD_BR[1] - GRAD_BL[1]) * t),
|
|
|
|
|
int(GRAD_BL[2] + (GRAD_BR[2] - GRAD_BL[2]) * t),
|
|
|
|
|
)
|
|
|
|
|
# Vertically blend top row → bottom row across each column.
|
|
|
|
|
out = Image.new("RGB", (w, h))
|
|
|
|
|
for y in range(h):
|
|
|
|
|
t = y / (h - 1) if h > 1 else 0.0
|
|
|
|
|
# Per-row blend of the two edge images.
|
|
|
|
|
row = Image.blend(top_row, bot_row, t)
|
|
|
|
|
out.paste(row, (0, y))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def round_corners(img: Image.Image, radius: int) -> Image.Image:
|
|
|
|
|
"""Apply rounded corners to img by masking alpha."""
|
|
|
|
|
mask = Image.new("L", img.size, 0)
|
|
|
|
|
ImageDraw.Draw(mask).rounded_rectangle(
|
|
|
|
|
(0, 0, img.size[0], img.size[1]), radius=radius, fill=255
|
|
|
|
|
)
|
|
|
|
|
out = img.convert("RGBA")
|
|
|
|
|
out.putalpha(mask)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compose_frame(inner_rgb: Image.Image, gradient_bg: Image.Image) -> Image.Image:
|
|
|
|
|
"""Resize an RGB frame to the inner target and paste it onto the
|
|
|
|
|
pre-rendered gradient with rounded corners. Returns an RGB image
|
|
|
|
|
of OUTER_W x OUTER_H."""
|
|
|
|
|
inner = inner_rgb
|
|
|
|
|
if inner.size != (INNER_W, INNER_H):
|
|
|
|
|
inner = inner.resize((INNER_W, INNER_H), Image.LANCZOS)
|
|
|
|
|
inner_rounded = round_corners(inner, CORNER_RADIUS)
|
|
|
|
|
canvas = gradient_bg.copy()
|
|
|
|
|
canvas.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
|
|
|
|
|
return canvas.convert("RGB")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compose_frame_natural(inner_rgb: Image.Image) -> Image.Image:
|
|
|
|
|
"""Frame an inner image at its native size with the same per-edge
|
|
|
|
|
padding as the fixed-size frame (100/60/100/61). Used for CLI
|
|
|
|
|
captures whose height varies per command — short ones stay short,
|
|
|
|
|
long ones stay long, and nothing gets resampled."""
|
|
|
|
|
inner_w, inner_h = inner_rgb.size
|
|
|
|
|
outer_w = inner_w + PAD_L + PAD_R
|
|
|
|
|
outer_h = inner_h + PAD_T + PAD_B
|
|
|
|
|
bg = make_gradient(outer_w, outer_h).convert("RGBA")
|
|
|
|
|
inner_rounded = round_corners(inner_rgb, CORNER_RADIUS)
|
|
|
|
|
bg.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
|
|
|
|
|
return bg.convert("RGB")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def frame_one(src: Path, natural: bool = False) -> None:
|
|
|
|
|
inner = Image.open(src).convert("RGB")
|
|
|
|
|
if natural:
|
|
|
|
|
out = compose_frame_natural(inner)
|
|
|
|
|
else:
|
|
|
|
|
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
|
|
|
|
|
out = compose_frame(inner, bg)
|
|
|
|
|
out.save(src, "PNG", optimize=True)
|
|
|
|
|
print(f"framed: {src}", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def frame_gif(src: Path) -> None:
|
|
|
|
|
"""Frame an animated GIF in place: every frame gets the same
|
2026-05-05 19:02:47 -04:00
|
|
|
mint-cyan gradient frame, then the result is re-encoded as a single-
|
2026-04-29 00:58:38 -04:00
|
|
|
palette GIF. Calls ffmpeg for the final encode (Pillow's GIF
|
|
|
|
|
output is noticeably worse for large animations).
|
|
|
|
|
"""
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
from PIL import ImageSequence
|
|
|
|
|
|
|
|
|
|
src_img = Image.open(src)
|
|
|
|
|
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
|
|
|
|
|
|
|
|
|
|
durations: list[int] = []
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="nyx-gif-frames-") as tmp:
|
|
|
|
|
tmp_path = Path(tmp)
|
|
|
|
|
for i, frame in enumerate(ImageSequence.Iterator(src_img)):
|
|
|
|
|
rgb = frame.convert("RGB")
|
|
|
|
|
composed = compose_frame(rgb, bg)
|
|
|
|
|
composed.save(tmp_path / f"{i:05d}.png", "PNG")
|
|
|
|
|
durations.append(int(frame.info.get("duration", 67)))
|
|
|
|
|
if not durations:
|
|
|
|
|
print(f"no frames in {src}", file=sys.stderr)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
avg_ms = sum(durations) / len(durations)
|
|
|
|
|
fps = max(1, round(1000.0 / avg_ms))
|
|
|
|
|
palette = tmp_path / "palette.png"
|
|
|
|
|
|
|
|
|
|
# palette pass
|
|
|
|
|
subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ffmpeg", "-y",
|
|
|
|
|
"-framerate", str(fps),
|
|
|
|
|
"-i", str(tmp_path / "%05d.png"),
|
|
|
|
|
"-vf", "palettegen=stats_mode=diff",
|
|
|
|
|
str(palette),
|
|
|
|
|
],
|
|
|
|
|
check=True,
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
)
|
|
|
|
|
# encode
|
|
|
|
|
subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ffmpeg", "-y",
|
|
|
|
|
"-framerate", str(fps),
|
|
|
|
|
"-i", str(tmp_path / "%05d.png"),
|
|
|
|
|
"-i", str(palette),
|
|
|
|
|
"-lavfi", "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
|
|
|
|
|
"-loop", "0",
|
|
|
|
|
str(src),
|
|
|
|
|
],
|
|
|
|
|
check=True,
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
)
|
|
|
|
|
print(f"framed gif: {src}", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
|
|
|
natural = False
|
|
|
|
|
if argv and argv[0] == "--natural":
|
|
|
|
|
natural = True
|
|
|
|
|
argv = argv[1:]
|
|
|
|
|
if not argv:
|
|
|
|
|
# No args: walk the default location.
|
|
|
|
|
root = Path(__file__).resolve().parent.parent / "assets" / "screenshots"
|
|
|
|
|
paths = sorted(p for p in root.rglob("*.png"))
|
|
|
|
|
else:
|
|
|
|
|
paths = [Path(p) for p in argv]
|
|
|
|
|
if not paths:
|
|
|
|
|
print("no PNGs to frame", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
for p in paths:
|
|
|
|
|
if not p.is_file():
|
|
|
|
|
print(f"skip (not a file): {p}", file=sys.stderr)
|
|
|
|
|
continue
|
|
|
|
|
if p.suffix.lower() == ".gif":
|
|
|
|
|
frame_gif(p)
|
|
|
|
|
else:
|
|
|
|
|
frame_one(p, natural=natural)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main(sys.argv[1:]))
|