refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt, docs/ into muxplex/ subdir in prep for packaging/sharing - Add brand assets to frontend/: favicon.ico, pwa-192/512.png, apple-touch-icon.png, wordmark-on-dark.svg - Update app: title → muxplex, header → wordmark SVG, brand color tokens in style.css, manifest.json updated with muxplex name and brand icons - Add design system: assets/branding/tokens.css (101 CSS custom properties), tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec) - Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG) - Add scripts/render-brand-assets.py for reproducible PNG generation - Add muxplex/README.md
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
muxplex brand asset renderer
|
||||
Usage: python3 render-brand-assets.py [--force] [--workdir PATH]
|
||||
|
||||
Renders all SVG sources to PNG/ICO for web, PWA, favicons, and OG images.
|
||||
Run from the ./muxplex/ directory or pass --workdir path.
|
||||
|
||||
Renderer priority: cairosvg > rsvg-convert > inkscape > imagemagick (convert/magick)
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Argument parsing
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
parser = ArgumentParser(description="Render muxplex brand assets")
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Re-render even if output exists"
|
||||
)
|
||||
parser.add_argument("--workdir", default=None, help="Base directory (default: cwd)")
|
||||
args = parser.parse_args()
|
||||
|
||||
WORKDIR = Path(args.workdir).resolve() if args.workdir else Path.cwd()
|
||||
ASSETS = WORKDIR / "assets" / "branding"
|
||||
SVG_DIR = ASSETS / "svg"
|
||||
FORCE = args.force
|
||||
|
||||
# Verify we're in the right place
|
||||
if not SVG_DIR.exists():
|
||||
sys.exit(
|
||||
f"[ERROR] Cannot find {SVG_DIR}\n"
|
||||
"Run from the muxplex/ directory or pass --workdir <path-to-muxplex-dir>"
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Renderer detection
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
RENDERER: str | None = None
|
||||
|
||||
if importlib.util.find_spec("cairosvg") is not None:
|
||||
RENDERER = "cairosvg"
|
||||
print("[renderer] cairosvg detected ✓")
|
||||
|
||||
if RENDERER is None and shutil.which("rsvg-convert"):
|
||||
RENDERER = "rsvg-convert"
|
||||
print("[renderer] rsvg-convert detected ✓")
|
||||
|
||||
if RENDERER is None and shutil.which("inkscape"):
|
||||
RENDERER = "inkscape"
|
||||
print("[renderer] inkscape detected ✓")
|
||||
|
||||
if RENDERER is None:
|
||||
for _magick_bin in ("magick", "convert"):
|
||||
if shutil.which(_magick_bin):
|
||||
RENDERER = f"imagemagick:{_magick_bin}"
|
||||
print(f"[renderer] ImageMagick ({_magick_bin}) detected ✓")
|
||||
break
|
||||
|
||||
if RENDERER is None:
|
||||
sys.exit(
|
||||
"[ERROR] No SVG renderer found.\n"
|
||||
"Install one of: cairosvg (pip install cairosvg), librsvg2-bin (rsvg-convert),\n"
|
||||
" inkscape, or imagemagick"
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pillow detection
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
HAS_PILLOW = importlib.util.find_spec("PIL") is not None
|
||||
if HAS_PILLOW:
|
||||
print("[pillow] Pillow detected ✓")
|
||||
else:
|
||||
print("[pillow] Pillow not available — ICO and OG composition will be skipped")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# SVG introspection helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_svg_dimensions(svg_path: Path) -> tuple[float | None, float | None]:
|
||||
"""Return (width, height) floats from the SVG root element."""
|
||||
tree = ET.parse(svg_path)
|
||||
root = tree.getroot()
|
||||
w = root.get("width")
|
||||
h = root.get("height")
|
||||
# Fall back to viewBox
|
||||
if not w or not h:
|
||||
vb = root.get("viewBox", "")
|
||||
parts = vb.replace(",", " ").split()
|
||||
if len(parts) == 4:
|
||||
w, h = parts[2], parts[3]
|
||||
if w and h:
|
||||
# Strip units (px, pt, etc.)
|
||||
w_f = float("".join(c for c in str(w) if c in "0123456789."))
|
||||
h_f = float("".join(c for c in str(h) if c in "0123456789."))
|
||||
return w_f, h_f
|
||||
return None, None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Core render function
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def render_svg(
|
||||
src: Path,
|
||||
dst: Path,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Render src SVG to dst PNG at the given width and/or height.
|
||||
If only one dimension is given the other is computed from aspect ratio.
|
||||
Returns True on success.
|
||||
"""
|
||||
if dst.exists() and not FORCE:
|
||||
print(f" [skip] {dst.relative_to(WORKDIR)}")
|
||||
return True
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Compute missing dimension from aspect ratio
|
||||
svg_w, svg_h = get_svg_dimensions(src)
|
||||
if svg_w and svg_h:
|
||||
aspect = svg_w / svg_h
|
||||
if width and not height:
|
||||
height = round(width / aspect)
|
||||
elif height and not width:
|
||||
width = round(height * aspect)
|
||||
# Final fallback dimensions
|
||||
final_w: int = width if width is not None else int(svg_w or 64)
|
||||
final_h: int = height if height is not None else int(svg_h or 64)
|
||||
|
||||
try:
|
||||
if RENDERER == "cairosvg":
|
||||
import cairosvg # type: ignore[import-untyped]
|
||||
|
||||
cairosvg.svg2png(
|
||||
url=str(src),
|
||||
write_to=str(dst),
|
||||
output_width=final_w,
|
||||
output_height=final_h,
|
||||
)
|
||||
|
||||
elif RENDERER == "rsvg-convert":
|
||||
subprocess.run(
|
||||
[
|
||||
"rsvg-convert",
|
||||
"-w",
|
||||
str(final_w),
|
||||
"-h",
|
||||
str(final_h),
|
||||
"-o",
|
||||
str(dst),
|
||||
str(src),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
elif RENDERER == "inkscape":
|
||||
subprocess.run(
|
||||
[
|
||||
"inkscape",
|
||||
"--export-type=png",
|
||||
f"--export-width={final_w}",
|
||||
f"--export-height={final_h}",
|
||||
f"--export-filename={dst}",
|
||||
str(src),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
else:
|
||||
# ImageMagick — RENDERER is "imagemagick:convert" or "imagemagick:magick"
|
||||
#
|
||||
# ImageMagick's built-in SVG renderer (MSVG) handles clip-paths
|
||||
# reliably only at the SVG's native pixel dimensions. Rendering at
|
||||
# any other size typically produces a 1-colour (transparent) result.
|
||||
#
|
||||
# Strategy:
|
||||
# 1. Render to a temp PNG at native SVG dimensions via ImageMagick.
|
||||
# 2. Scale the temp PNG to the requested target size with Pillow
|
||||
# (LANCZOS — high quality, preserves edges).
|
||||
# 3. Fall back to a direct ImageMagick resize if Pillow is absent
|
||||
# (quality may be poor for upscaling).
|
||||
assert RENDERER is not None # guaranteed by startup check
|
||||
bin_name = RENDERER.split(":")[1]
|
||||
native_w, native_h = get_svg_dimensions(src)
|
||||
|
||||
if native_w and native_h and HAS_PILLOW:
|
||||
from PIL import Image
|
||||
|
||||
tmp_native = dst.parent / f"_tmp_native_{src.stem}.png"
|
||||
# Render at native SVG size (no resize flag → ImageMagick uses
|
||||
# the dimensions declared in the SVG header).
|
||||
subprocess.run(
|
||||
[bin_name, "-background", "none", str(src), str(tmp_native)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Pillow: open → resize with LANCZOS → save
|
||||
img = Image.open(tmp_native).convert("RGBA")
|
||||
img = img.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
||||
img.save(str(dst))
|
||||
tmp_native.unlink(missing_ok=True)
|
||||
else:
|
||||
# No Pillow — attempt a direct ImageMagick resize.
|
||||
# Works acceptably for downscaling; upscaling may be imprecise.
|
||||
subprocess.run(
|
||||
[
|
||||
bin_name,
|
||||
"-background",
|
||||
"none",
|
||||
"-resize",
|
||||
f"{final_w}x{final_h}!",
|
||||
str(src),
|
||||
str(dst),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
print(f" [ok] {dst.relative_to(WORKDIR)} ({final_w}×{final_h})")
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
print(f" [FAIL] {dst.relative_to(WORKDIR)} — {exc}")
|
||||
return False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Generated-file registry (for summary table)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
generated: list[dict[str, str]] = []
|
||||
|
||||
|
||||
def track(path: Path, status: str = "ok") -> None:
|
||||
size_kb = f"{path.stat().st_size / 1024:.1f}K" if path.exists() else "—"
|
||||
generated.append(
|
||||
{"path": str(path.relative_to(WORKDIR)), "size": size_kb, "status": status}
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 1 — App icons
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── App icons ─────────────────────────────────────────────")
|
||||
ICON_SRC = SVG_DIR / "icon" / "muxplex-icon-dark.svg"
|
||||
ICONS_DIR = ASSETS / "icons"
|
||||
ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 192, 256, 512, 1024]
|
||||
|
||||
for sz in ICON_SIZES:
|
||||
dst = ICONS_DIR / f"muxplex-icon-{sz}.png"
|
||||
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||
if dst.exists():
|
||||
track(dst, "ok" if ok else "fail")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 2 — Favicons
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── Favicons ──────────────────────────────────────────────")
|
||||
FAV_DIR = ASSETS / "favicons"
|
||||
|
||||
for sz in [16, 32, 48]:
|
||||
dst = FAV_DIR / f"favicon-{sz}.png"
|
||||
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||
if dst.exists():
|
||||
track(dst)
|
||||
|
||||
# apple-touch-icon 180×180
|
||||
ati = FAV_DIR / "apple-touch-icon.png"
|
||||
ok = render_svg(ICON_SRC, ati, width=180, height=180)
|
||||
if ati.exists():
|
||||
track(ati)
|
||||
|
||||
# favicon.ico (multi-res 16+32+48) — requires Pillow
|
||||
ico_path = FAV_DIR / "favicon.ico"
|
||||
if not ico_path.exists() or FORCE:
|
||||
if HAS_PILLOW:
|
||||
from PIL import Image
|
||||
|
||||
fav_pngs = [FAV_DIR / f"favicon-{s}.png" for s in [16, 32, 48]]
|
||||
if all(p.exists() for p in fav_pngs):
|
||||
imgs = [Image.open(p).convert("RGBA") for p in fav_pngs]
|
||||
imgs[0].save(
|
||||
str(ico_path),
|
||||
format="ICO",
|
||||
sizes=[(s, s) for s in [16, 32, 48]],
|
||||
append_images=imgs[1:],
|
||||
)
|
||||
print(f" [ok] {ico_path.relative_to(WORKDIR)} (multi-res ICO)")
|
||||
else:
|
||||
print(" [WARN] favicon PNGs missing — ICO skipped")
|
||||
else:
|
||||
print(" [skip] favicon.ico — Pillow not available")
|
||||
if ico_path.exists():
|
||||
track(ico_path)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 3 — PWA icons
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── PWA icons ─────────────────────────────────────────────")
|
||||
PWA_DIR = ASSETS / "pwa"
|
||||
|
||||
for sz in [192, 512]:
|
||||
dst = PWA_DIR / f"pwa-{sz}.png"
|
||||
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||
if dst.exists():
|
||||
track(dst)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 4 — Wordmark PNGs
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── Wordmarks ─────────────────────────────────────────────")
|
||||
WM_DIR = ASSETS / "wordmark"
|
||||
|
||||
for variant in ["dark", "light"]:
|
||||
src = SVG_DIR / "wordmark" / f"wordmark-on-{variant}.svg"
|
||||
for h in [32, 64]:
|
||||
dst = WM_DIR / f"wordmark-on-{variant}-{h}.png"
|
||||
ok = render_svg(src, dst, height=h)
|
||||
if dst.exists():
|
||||
track(dst)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 5 — Lockup PNGs
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── Lockups ───────────────────────────────────────────────")
|
||||
LOCKUP_DIR = ASSETS / "lockup"
|
||||
|
||||
for variant in ["dark", "light"]:
|
||||
src = SVG_DIR / "lockup" / f"lockup-on-{variant}.svg"
|
||||
for h in [32, 64]:
|
||||
dst = LOCKUP_DIR / f"lockup-on-{variant}-{h}.png"
|
||||
ok = render_svg(src, dst, height=h)
|
||||
if dst.exists():
|
||||
track(dst)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Step 6 — OG images (1200×630)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n── OG images ─────────────────────────────────────────────")
|
||||
OG_DIR = ASSETS / "og"
|
||||
OG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
OG_CONFIGS: list[dict] = [
|
||||
{
|
||||
"variant": "dark",
|
||||
"bg_hex": "#0D1117",
|
||||
"bg_rgb": (13, 17, 23),
|
||||
"lockup_src": SVG_DIR / "lockup" / "lockup-on-dark.svg",
|
||||
"out": OG_DIR / "og-dark.png",
|
||||
},
|
||||
{
|
||||
"variant": "light",
|
||||
"bg_hex": "#FFFFFF",
|
||||
"bg_rgb": (255, 255, 255),
|
||||
"lockup_src": SVG_DIR / "lockup" / "lockup-on-light.svg",
|
||||
"out": OG_DIR / "og-light.png",
|
||||
},
|
||||
]
|
||||
|
||||
for cfg in OG_CONFIGS:
|
||||
out_path: Path = cfg["out"]
|
||||
if out_path.exists() and not FORCE:
|
||||
print(f" [skip] {out_path.relative_to(WORKDIR)}")
|
||||
track(out_path, "skip")
|
||||
continue
|
||||
|
||||
if HAS_PILLOW:
|
||||
from PIL import Image
|
||||
|
||||
# Render lockup at height=200 into a temp file
|
||||
tmp_lockup = OG_DIR / f"_tmp_lockup_{cfg['variant']}.png"
|
||||
ok = render_svg(cfg["lockup_src"], tmp_lockup, height=200)
|
||||
if not ok or not tmp_lockup.exists():
|
||||
print(f" [FAIL] OG {cfg['variant']} — lockup render failed")
|
||||
continue
|
||||
|
||||
bg = Image.new("RGB", (1200, 630), color=cfg["bg_rgb"])
|
||||
lockup_img = Image.open(tmp_lockup).convert("RGBA")
|
||||
|
||||
# Centre the lockup on the canvas
|
||||
x = (1200 - lockup_img.width) // 2
|
||||
y = (630 - lockup_img.height) // 2
|
||||
bg.paste(lockup_img, (x, y), lockup_img)
|
||||
bg.save(str(out_path))
|
||||
tmp_lockup.unlink(missing_ok=True)
|
||||
print(
|
||||
f" [ok] {out_path.relative_to(WORKDIR)} (1200×630, bg={cfg['bg_hex']})"
|
||||
)
|
||||
|
||||
else:
|
||||
# Fallback: render lockup at height=200, no canvas composition
|
||||
ok = render_svg(cfg["lockup_src"], out_path, height=200)
|
||||
if ok:
|
||||
print(
|
||||
f" [NOTE] {out_path.relative_to(WORKDIR)} — "
|
||||
"Pillow unavailable; rendered lockup only (no 1200×630 canvas).\n"
|
||||
" Install Pillow and re-run to get proper OG images."
|
||||
)
|
||||
|
||||
if out_path.exists():
|
||||
track(out_path)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Summary table
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n" + "═" * 70)
|
||||
print(f" {'FILE':<52} {'SIZE':>6} STATUS")
|
||||
print("─" * 70)
|
||||
for item in generated:
|
||||
status_icon = (
|
||||
"✓" if item["status"] == "ok" else ("·" if item["status"] == "skip" else "✗")
|
||||
)
|
||||
print(f" {item['path']:<52} {item['size']:>6} {status_icon}")
|
||||
print("═" * 70)
|
||||
print(f" Total: {len(generated)} files\n")
|
||||
Reference in New Issue
Block a user