verify_assets.py — standalone auditor for a directory of web assets produced
by generate_favicons.py / generate_og_images.py.
Unlike --validate (which only checks files as they're generated in the same
run), this script re-audits an EXISTING output directory after the fact:
right files present, correct dimensions, correct format, sane file sizes,
and naming conventions match what the skill's HTML tags expect.
verify_assets.py <dir> [--set favicons|og|all]
Exit code is non-zero if any hard check fails. Falls back gracefully across
three inspection methods, in order:
1. Pillow (PIL) — exact dimensions + format, if installed
2. `identify` (ImageMagick) — exact dimensions + format, if on PATH
3. raw magic-byte / PNG IHDR parsing (stdlib only) — format + dimensions,
always available, used if neither of the above is present
No required check is ever skipped outright: dimension/format verification
always runs via the stdlib fallback, even with zero third-party tools.
# Expected output sets, mirroring generate_favicons.py / generate_og_images.py.
"favicon-16x16.png": (16, 16, "png"),
"favicon-32x32.png": (32, 32, "png"),
"favicon-96x96.png": (96, 96, "png"),
"favicon.ico": (None, None, "ico"),
"apple-touch-icon.png": (180, 180, "png"),
"android-chrome-192x192.png": (192, 192, "png"),
"android-chrome-512x512.png": (512, 512, "png"),
"og-image.png": (1200, 630, "png"),
"twitter-image.png": (1200, 675, "png"),
"og-square.png": (1200, 1200, "png"),
# Platform file-size ceilings (bytes) — mirrors lib/validators.py PLATFORM_REQUIREMENTS.
MAX_OG_BYTES = 8 * 1024 * 1024 # 8MB (Facebook/WhatsApp ceiling; also covers Twitter/LinkedIn's 5MB)
MAX_ICON_BYTES = 200 * 1024 # generous ceiling — an icon this large signals an unoptimized export
def try_pillow_dims(path):
with Image.open(path) as im:
return (im.width, im.height, im.format.lower() if im.format else None)
def try_imagemagick_dims(path):
if not _which("identify"):
["identify", "-format", "%w %h %m", str(path)],
capture_output=True, text=True, timeout=10,
parts = out.stdout.strip().split()
w, h, fmt = int(parts[0]), int(parts[1]), parts[2].lower()
return shutil.which(cmd) is not None
"""Read width/height straight out of the PNG IHDR chunk. stdlib only."""
with open(path, "rb") as f:
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
width, height = struct.unpack(">II", header[16:24])
return (width, height, "png")
"""Confirm the ICO magic header and at least one embedded image directory entry."""
with open(path, "rb") as f:
reserved, img_type, count = struct.unpack("<HHH", header)
return reserved == 0 and img_type == 1 and count >= 1
"""Return (width, height, format) using the best available method, or None."""
dims = try_pillow_dims(path)
dims = try_imagemagick_dims(path)
return dims, "ImageMagick identify"
if path.suffix.lower() == ".png":
dims = raw_png_dims(path)
return dims, "raw PNG IHDR (stdlib)"
def check_set(out_dir, expected, label, max_bytes):
print(f"\n== {label} ==")
for filename, (want_w, want_h, want_fmt) in expected.items():
path = out_dir / filename
error(f"missing expected file: {filename}")
size_bytes = path.stat().st_size
error(f"{filename}: zero-byte file")
if size_bytes > max_bytes:
error(f"{filename}: {size_bytes:,} bytes exceeds the {max_bytes:,}-byte ceiling")
ok(f"{filename}: {size_bytes:,} bytes (within ceiling)")
ok(f"{filename}: valid ICO container")
error(f"{filename}: does not look like a valid ICO file (bad magic header)")
dims, method = inspect_image(path)
warn(f"{filename}: could not determine dimensions/format (no Pillow, no ImageMagick, non-PNG stdlib fallback unavailable)")
if want_w is not None and (w, h) != (want_w, want_h):
error(f"{filename}: is {w}x{h}, expected {want_w}x{want_h} (via {method})")
ok(f"{filename}: {w}x{h} confirmed (via {method})")
if fmt and want_fmt and fmt != want_fmt:
error(f"{filename}: format is {fmt}, expected {want_fmt}")
def check_naming_conventions(out_dir):
print("\n== naming conventions ==")
for p in out_dir.glob("*"):
if name.startswith(".") or name in ("site.webmanifest",):
if p.suffix.lower() not in (".png", ".ico", ".jpg", ".jpeg", ".webp"):
for n in sorted(set(bad_names)):
error(f"non-conforming filename (must be lowercase, no spaces): {n}")
ok("all asset filenames are lowercase with no spaces")
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dir", help="Directory containing generated web assets (e.g. public/)")
parser.add_argument("--set", choices=["favicons", "og", "all"], default="all",
help="Which expected file set to check (default: all)")
args = parser.parse_args()
print(f"Usage error: {out_dir} is not a directory", file=sys.stderr)
print(f"== Verifying web assets in {out_dir} ==")
if not _which("identify"):
warn("neither Pillow nor ImageMagick 'identify' found — falling back to raw PNG IHDR parsing (stdlib); .jpg/.webp dimension checks will be skipped")
if args.set in ("favicons", "all"):
check_set(out_dir, FAVICON_FILES, "favicon / app-icon set", MAX_ICON_BYTES)
if args.set in ("og", "all"):
check_set(out_dir, OG_FILES, "Open Graph / social image set", MAX_OG_BYTES)
check_naming_conventions(out_dir)
print(f"All checks passed ({WARN_COUNT} warning(s)).")
print("One or more checks failed — see ❌ lines above.")
if __name__ == "__main__":