nyx/scripts/frame-screenshots.py

221 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Frame Nyx screenshots with the brand mint-led four-corner gradient.
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
four-corner mint-led gradient (TL #72f3d7, TR #ff6aa2,
BL #f8c56b, BR #4cc9ff).
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
# 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
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
mint-cyan gradient frame, then the result is re-encoded as a single-
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:]))