nyx/scripts/frame-screenshots.py
Eli Peter 82f18184b1
Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:58:38 -04:00

222 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Frame Nyx screenshots with the brand purple 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
diagonal purple gradient (top-left #8a5bf5 → bottom-right #4d1d97).
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. Sampled from the existing CLI
# screenshots so every framed asset matches: top-left is the lightest
# (Tailwind violet-500), the off-diagonal corners are violet-600, and
# bottom-right is violet-900.
GRAD_TL = (139, 92, 246) # #8b5cf6 violet-500
GRAD_TR = (124, 58, 237) # #7c3aed violet-600
GRAD_BL = (124, 58, 237) # #7c3aed violet-600
GRAD_BR = ( 76, 29, 149) # #4c1d95 violet-900
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
purple 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:]))