diff --git a/.github/workflows/brand.yml b/.github/workflows/brand.yml
new file mode 100644
index 0000000..2fbd97f
--- /dev/null
+++ b/.github/workflows/brand.yml
@@ -0,0 +1,30 @@
+name: Brand
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ paths:
+ - "brand/**"
+ - "scripts/brand_generate.py"
+ - "tests/test_brand_generate.py"
+ - "Taskfile.yml"
+ - ".github/workflows/brand.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ brand:
+ name: Brand assets
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
+ with:
+ python-version: "3.11"
+ - name: Install librsvg2-bin
+ run: sudo apt-get update && sudo apt-get install -y librsvg2-bin
+ - run: python3 -m unittest tests.test_brand_generate.BrandGenerateTests.test_rsvg_convert_integration -v
+ - run: python3 scripts/brand_generate.py --check
diff --git a/Taskfile.yml b/Taskfile.yml
index c723296..5117f6e 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -28,3 +28,13 @@ tasks:
- zensical build --strict
- python -m unittest tests.test_check_docs -v
- python scripts/check_docs.py site
+
+ brand:generate:
+ desc: Generate ffmpegfyMe brand assets
+ cmds:
+ - python3 scripts/brand_generate.py --generate
+
+ brand:check:
+ desc: Check ffmpegfyMe brand assets are current
+ cmds:
+ - python3 scripts/brand_generate.py --check
diff --git a/brand/data/brand.json b/brand/data/brand.json
new file mode 100644
index 0000000..ab99f27
--- /dev/null
+++ b/brand/data/brand.json
@@ -0,0 +1,10 @@
+{
+ "base": "#24273a",
+ "mantle": "#1e2030",
+ "crust": "#181926",
+ "text": "#cad3f5",
+ "subtext1": "#b8c0e0",
+ "surface0": "#363a4f",
+ "surface1": "#494d64",
+ "sky": "#91d7e3"
+}
diff --git a/brand/data/facts.json b/brand/data/facts.json
new file mode 100644
index 0000000..dc3b823
--- /dev/null
+++ b/brand/data/facts.json
@@ -0,0 +1,14 @@
+{
+ "output_formats": [
+ "MP4",
+ "MKV"
+ ],
+ "max_attempts_per_item": 3,
+ "default_inactivity_timeout_seconds": 60,
+ "social_pillars": [
+ "Authenticated HTTP/HLS",
+ "MP4/MKV stream copy",
+ "Resumable batches with retries"
+ ],
+ "readme_banner_tagline": "Download HTTP/HLS streams as MP4 or MKV — no transcoding."
+}
diff --git a/brand/generated/favicon.svg b/brand/generated/favicon.svg
new file mode 100644
index 0000000..108aa7c
--- /dev/null
+++ b/brand/generated/favicon.svg
@@ -0,0 +1,29 @@
+
diff --git a/brand/generated/logo.svg b/brand/generated/logo.svg
new file mode 100644
index 0000000..3b32a44
--- /dev/null
+++ b/brand/generated/logo.svg
@@ -0,0 +1,39 @@
+
diff --git a/brand/generated/readme-banner.svg b/brand/generated/readme-banner.svg
new file mode 100644
index 0000000..365166f
--- /dev/null
+++ b/brand/generated/readme-banner.svg
@@ -0,0 +1,62 @@
+
diff --git a/brand/generated/social-preview.json b/brand/generated/social-preview.json
new file mode 100644
index 0000000..14f0205
--- /dev/null
+++ b/brand/generated/social-preview.json
@@ -0,0 +1,5 @@
+{
+ "png_sha256": "da3a098556eb90e92a785d3537a090ce9f9a81a89da916c2bf34eaf52718e295",
+ "rsvg_convert_version": "rsvg-convert version 2.63.4\n\nlibraries used:\n cairo 1.18.6\n pango 1.58.2\n harfbuzz 15.0.0\n fontconfig 2.18.3",
+ "source_sha256": "cb7131a6cb31870dcbcac8f83576ce5f6fee7d4edf127157987e0641f151b656"
+}
diff --git a/brand/generated/social-preview.png b/brand/generated/social-preview.png
new file mode 100644
index 0000000..db2b3be
Binary files /dev/null and b/brand/generated/social-preview.png differ
diff --git a/brand/generated/social-preview.svg b/brand/generated/social-preview.svg
new file mode 100644
index 0000000..780a368
--- /dev/null
+++ b/brand/generated/social-preview.svg
@@ -0,0 +1,40 @@
+
diff --git a/brand/src/favicon.svg b/brand/src/favicon.svg
new file mode 100644
index 0000000..4247175
--- /dev/null
+++ b/brand/src/favicon.svg
@@ -0,0 +1,29 @@
+
diff --git a/brand/src/logo.svg b/brand/src/logo.svg
new file mode 100644
index 0000000..0b0d47e
--- /dev/null
+++ b/brand/src/logo.svg
@@ -0,0 +1,39 @@
+
diff --git a/brand/src/readme-banner.svg b/brand/src/readme-banner.svg
new file mode 100644
index 0000000..6e01252
--- /dev/null
+++ b/brand/src/readme-banner.svg
@@ -0,0 +1,62 @@
+
diff --git a/brand/src/social-preview.svg b/brand/src/social-preview.svg
new file mode 100644
index 0000000..b217c79
--- /dev/null
+++ b/brand/src/social-preview.svg
@@ -0,0 +1,40 @@
+
diff --git a/scripts/brand_generate.py b/scripts/brand_generate.py
new file mode 100644
index 0000000..802ea44
--- /dev/null
+++ b/scripts/brand_generate.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+"""Deterministic brand asset generator for ffmpegfyMe."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+BRAND_DIR = ROOT_DIR / "brand"
+SRC_DIR = BRAND_DIR / "src"
+DATA_DIR = BRAND_DIR / "data"
+GENERATED_DIR = BRAND_DIR / "generated"
+
+BRAND_DATA_FILE = DATA_DIR / "brand.json"
+FACTS_DATA_FILE = DATA_DIR / "facts.json"
+
+SOURCE_NAMES = ("favicon.svg", "logo.svg", "readme-banner.svg", "social-preview.svg")
+GENERATED_NAMES = ("favicon.svg", "logo.svg", "readme-banner.svg", "social-preview.png", "social-preview.svg", "social-preview.json")
+SOCIAL_PREVIEW_METADATA = "social-preview.json"
+
+SOCIAL_PREVIEW_WIDTH = 1280
+SOCIAL_PREVIEW_HEIGHT = 640
+SOCIAL_PREVIEW_MAX_BYTES = 1_000_000
+PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+PALETTE_KEYS = ("base", "mantle", "crust", "text", "subtext1", "surface0", "surface1", "sky")
+HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
+
+
+class BrandError(Exception):
+ """Raised when brand data, templates, or generated assets are invalid."""
+
+
+def _read_json(path: Path) -> dict:
+ with path.open(encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def validate_palette(palette: dict) -> list[str]:
+ if not isinstance(palette, dict):
+ return ["brand palette must be a JSON object"]
+ errors: list[str] = []
+ for key in PALETTE_KEYS:
+ value = palette.get(key)
+ if not isinstance(value, str) or not HEX_COLOR_PATTERN.fullmatch(value):
+ errors.append(f"brand palette entry {key} must be a hex color like #24273a")
+ return errors
+
+
+def validate_facts(facts: dict) -> list[str]:
+ if not isinstance(facts, dict):
+ return ["brand facts must be a JSON object"]
+ errors: list[str] = []
+ if facts.get("output_formats") != ["MP4", "MKV"]:
+ errors.append("brand facts output_formats must be exactly [MP4, MKV]")
+ if facts.get("max_attempts_per_item") != 3:
+ errors.append("brand facts max_attempts_per_item must be 3")
+ if facts.get("default_inactivity_timeout_seconds") != 60:
+ errors.append("brand facts default_inactivity_timeout_seconds must be 60")
+ pillars = facts.get("social_pillars")
+ if not isinstance(pillars, list) or len(pillars) != 3 or not all(isinstance(pillar, str) and pillar.strip() for pillar in pillars):
+ errors.append("brand facts social_pillars must list three non-empty strings")
+ tagline = facts.get("readme_banner_tagline")
+ if not isinstance(tagline, str) or not tagline.strip():
+ errors.append("brand facts readme_banner_tagline must be a non-empty string")
+ return errors
+
+
+def _token_values(palette: dict, facts: dict) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for key, value in palette.items():
+ if isinstance(key, str) and isinstance(value, str):
+ values[key.upper()] = value
+ pillars = facts.get("social_pillars") or []
+ values["PILLARS"] = ", ".join(pillars)
+ values["PILLAR_1"] = pillars[0] if len(pillars) > 0 else ""
+ values["PILLAR_2"] = pillars[1] if len(pillars) > 1 else ""
+ values["PILLAR_3"] = pillars[2] if len(pillars) > 2 else ""
+ values["TAGLINE"] = facts.get("readme_banner_tagline") or ""
+ return values
+
+
+def _finalize(svg_text: str, palette: dict, facts: dict) -> str:
+ rendered = svg_text
+ for token, value in sorted(_token_values(palette, facts).items()):
+ rendered = rendered.replace("{{" + token + "}}", value)
+ if "{{" in rendered:
+ raise BrandError("brand SVG template has unresolved placeholders")
+ return rendered
+
+
+def _load_source(name: str) -> str:
+ path = SRC_DIR / name
+ if not path.is_file():
+ raise BrandError(f"missing brand source template: brand/src/{name}")
+ return path.read_text(encoding="utf-8")
+
+
+def render_logo_svg(palette: dict, facts: dict) -> str:
+ return _finalize(_load_source("logo.svg"), palette, facts)
+
+
+def render_favicon_svg(palette: dict, facts: dict) -> str:
+ return _finalize(_load_source("favicon.svg"), palette, facts)
+
+
+def render_readme_banner_svg(palette: dict, facts: dict) -> str:
+ return _finalize(_load_source("readme-banner.svg"), palette, facts)
+
+
+def render_social_preview_svg(palette: dict, facts: dict) -> str:
+ return _finalize(_load_source("social-preview.svg"), palette, facts)
+
+
+def render_social_preview_png(svg_text: str) -> bytes:
+ with tempfile.TemporaryDirectory(prefix="ffmpegfy-brand-") as tmp_dir:
+ svg_path = Path(tmp_dir) / "social-preview.svg"
+ png_path = Path(tmp_dir) / "social-preview.png"
+ svg_path.write_text(svg_text, encoding="utf-8")
+ try:
+ completed = subprocess.run(
+ ["rsvg-convert", "--output", str(png_path), str(svg_path)],
+ capture_output=True,
+ check=False,
+ )
+ except FileNotFoundError as error:
+ raise BrandError("rsvg-convert is not installed; install librsvg2-bin to generate brand PNG assets") from error
+ if completed.returncode != 0:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ raise BrandError(f"rsvg-convert failed to render social preview PNG: {detail}")
+ return png_path.read_bytes()
+
+
+def rsvg_convert_version() -> str:
+ try:
+ completed = subprocess.run(["rsvg-convert", "--version"], capture_output=True, check=False)
+ except FileNotFoundError as error:
+ raise BrandError("rsvg-convert is not installed; install librsvg2-bin to generate brand PNG assets") from error
+ if completed.returncode != 0:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ raise BrandError(f"rsvg-convert failed to report its version: {detail}")
+ return completed.stdout.decode("utf-8", errors="replace").strip()
+
+
+def validate_social_preview_png(png_bytes: bytes) -> list[str]:
+ if png_bytes[:8] != PNG_SIGNATURE:
+ return ["social preview PNG must start with a PNG signature"]
+ if len(png_bytes) < 24:
+ return ["social preview PNG must include an IHDR chunk"]
+ width = int.from_bytes(png_bytes[16:20], "big")
+ height = int.from_bytes(png_bytes[20:24], "big")
+ errors: list[str] = []
+ if width != SOCIAL_PREVIEW_WIDTH or height != SOCIAL_PREVIEW_HEIGHT:
+ errors.append(f"social preview PNG must be {SOCIAL_PREVIEW_WIDTH}x{SOCIAL_PREVIEW_HEIGHT}, got {width}x{height}")
+ if len(png_bytes) > SOCIAL_PREVIEW_MAX_BYTES:
+ errors.append(f"social preview PNG must be under {SOCIAL_PREVIEW_MAX_BYTES} bytes, got {len(png_bytes)}")
+ return errors
+
+
+def render_generated_assets(palette: dict, facts: dict) -> dict[str, bytes]:
+ errors = validate_palette(palette) + validate_facts(facts)
+ if errors:
+ raise BrandError("brand data validation failed: " + "; ".join(errors))
+ logo_svg = render_logo_svg(palette, facts)
+ favicon_svg = render_favicon_svg(palette, facts)
+ readme_banner_svg = render_readme_banner_svg(palette, facts)
+ social_preview_svg = render_social_preview_svg(palette, facts)
+ png_bytes = render_social_preview_png(social_preview_svg)
+ png_errors = validate_social_preview_png(png_bytes)
+ if png_errors:
+ raise BrandError("social preview PNG validation failed: " + "; ".join(png_errors))
+ metadata = {
+ "source_sha256": hashlib.sha256(social_preview_svg.encode("utf-8")).hexdigest(),
+ "png_sha256": hashlib.sha256(png_bytes).hexdigest(),
+ "rsvg_convert_version": rsvg_convert_version(),
+ }
+ return {
+ "favicon.svg": favicon_svg.encode("utf-8"),
+ "logo.svg": logo_svg.encode("utf-8"),
+ "readme-banner.svg": readme_banner_svg.encode("utf-8"),
+ "social-preview.svg": social_preview_svg.encode("utf-8"),
+ "social-preview.png": png_bytes,
+ SOCIAL_PREVIEW_METADATA: (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8"),
+ }
+
+
+def check_generated_assets(root: Path, assets: dict[str, bytes]) -> list[str]:
+ errors: list[str] = []
+ generated_dir = root / "brand" / "generated"
+ with tempfile.TemporaryDirectory(prefix="ffmpegfy-brand-check-") as tmp_dir:
+ temp_dir = Path(tmp_dir)
+ for filename, content in sorted(assets.items()):
+ (temp_dir / filename).write_bytes(content)
+ for filename, content in sorted(assets.items()):
+ temp_path = temp_dir / filename
+ path = generated_dir / filename
+ if not path.is_file():
+ errors.append(f"missing generated asset: brand/generated/{filename}")
+ continue
+ if path.read_bytes() != temp_path.read_bytes():
+ errors.append(f"stale generated asset: brand/generated/{filename}")
+ return errors
+
+
+def write_generated_assets(root: Path, assets: dict[str, bytes]) -> None:
+ generated_dir = root / "brand" / "generated"
+ generated_dir.mkdir(parents=True, exist_ok=True)
+ for filename, content in sorted(assets.items()):
+ (generated_dir / filename).write_bytes(content)
+
+
+def generate_brand_assets(root: Path) -> dict[str, bytes]:
+ palette = _read_json(root / "brand" / "data" / "brand.json")
+ facts = _read_json(root / "brand" / "data" / "facts.json")
+ return render_generated_assets(palette, facts)
+
+
+def check_brand_assets(root: Path) -> list[str]:
+ palette = _read_json(root / "brand" / "data" / "brand.json")
+ facts = _read_json(root / "brand" / "data" / "facts.json")
+ errors = validate_palette(palette) + validate_facts(facts)
+ if errors:
+ raise BrandError("brand data validation failed: " + "; ".join(errors))
+ svg_assets = {
+ "favicon.svg": render_favicon_svg(palette, facts).encode("utf-8"),
+ "logo.svg": render_logo_svg(palette, facts).encode("utf-8"),
+ "readme-banner.svg": render_readme_banner_svg(palette, facts).encode("utf-8"),
+ "social-preview.svg": render_social_preview_svg(palette, facts).encode("utf-8"),
+ }
+ errors = check_generated_assets(root, svg_assets)
+ generated_dir = root / "brand" / "generated"
+ png_path = generated_dir / "social-preview.png"
+ metadata_path = generated_dir / SOCIAL_PREVIEW_METADATA
+ if not png_path.is_file():
+ errors.append("missing generated asset: brand/generated/social-preview.png")
+ return errors
+ if not metadata_path.is_file():
+ errors.append("missing generated asset: brand/generated/social-preview.json")
+ return errors
+ try:
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ errors.append("invalid generated metadata: brand/generated/social-preview.json")
+ return errors
+ if not isinstance(metadata, dict):
+ errors.append("invalid generated metadata: brand/generated/social-preview.json")
+ return errors
+ png_bytes = png_path.read_bytes()
+ source_hash = hashlib.sha256(svg_assets["social-preview.svg"]).hexdigest()
+ png_hash = hashlib.sha256(png_bytes).hexdigest()
+ if metadata.get("source_sha256") != source_hash:
+ errors.append("stale generated metadata: brand/generated/social-preview.json source_sha256")
+ if metadata.get("png_sha256") != png_hash:
+ errors.append("stale generated metadata: brand/generated/social-preview.json png_sha256")
+ if not isinstance(metadata.get("rsvg_convert_version"), str) or not metadata["rsvg_convert_version"].strip():
+ errors.append("invalid generated metadata: brand/generated/social-preview.json rsvg_convert_version")
+ errors.extend(validate_social_preview_png(png_bytes))
+ return errors
+
+
+def write_brand_assets(root: Path) -> None:
+ assets = generate_brand_assets(root)
+ write_generated_assets(root, assets)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Generate and check ffmpegfyMe brand assets.")
+ parser.add_argument("--generate", action="store_true", help="Write generated brand assets.")
+ parser.add_argument("--check", action="store_true", help="Check generated brand assets are current.")
+ args = parser.parse_args(argv)
+ if args.generate == args.check:
+ parser.error("specify exactly one of --generate or --check")
+ try:
+ if args.generate:
+ assets = generate_brand_assets(ROOT_DIR)
+ write_generated_assets(ROOT_DIR, assets)
+ for filename in sorted(assets):
+ content = assets[filename]
+ detail = f"{len(content)} bytes" if filename.endswith(".png") else "JSON" if filename.endswith(".json") else "SVG"
+ print(f"generated brand asset: brand/generated/{filename} ({detail})")
+ else:
+ errors = check_brand_assets(ROOT_DIR)
+ if errors:
+ for error in errors:
+ print(f"brand check failed: {error}", file=sys.stderr)
+ return 1
+ print("Brand assets are up to date.")
+ except BrandError as error:
+ print(f"brand error: {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_brand_generate.py b/tests/test_brand_generate.py
new file mode 100644
index 0000000..ce3049a
--- /dev/null
+++ b/tests/test_brand_generate.py
@@ -0,0 +1,177 @@
+import json
+import shutil
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+from pathlib import Path
+
+from scripts.brand_generate import (
+ BrandError,
+ check_brand_assets,
+ check_generated_assets,
+ render_favicon_svg,
+ render_generated_assets,
+ render_logo_svg,
+ render_readme_banner_svg,
+ render_social_preview_svg,
+ validate_facts,
+ validate_palette,
+ validate_social_preview_png,
+ write_generated_assets,
+)
+
+
+VALID_PALETTE = {
+ "base": "#24273a",
+ "mantle": "#1e2030",
+ "crust": "#181926",
+ "text": "#cad3f5",
+ "subtext1": "#b8c0e0",
+ "surface0": "#363a4f",
+ "surface1": "#494d64",
+ "sky": "#91d7e3",
+}
+
+VALID_FACTS = {
+ "output_formats": ["MP4", "MKV"],
+ "max_attempts_per_item": 3,
+ "default_inactivity_timeout_seconds": 60,
+ "social_pillars": [
+ "Authenticated HTTP/HLS",
+ "MP4/MKV stream copy",
+ "Resumable batches with retries",
+ ],
+ "readme_banner_tagline": "Download HTTP/HLS streams as MP4 or MKV — no transcoding.",
+}
+
+
+class BrandGenerateTests(unittest.TestCase):
+ @staticmethod
+ def valid_png(width: int = 1280, height: int = 640) -> bytes:
+ return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0dIHDR" + width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00" + b"\x00" * 20
+
+ def render_assets_without_rsvg(self) -> dict[str, bytes]:
+ with patch("scripts.brand_generate.render_social_preview_png", return_value=self.valid_png()), patch(
+ "scripts.brand_generate.rsvg_convert_version", return_value="rsvg-convert mock"
+ ):
+ return render_generated_assets(VALID_PALETTE, VALID_FACTS)
+
+ def test_palette_requires_accepted_hex_colors(self) -> None:
+ self.assertEqual(validate_palette(VALID_PALETTE), [])
+
+ invalid = dict(VALID_PALETTE, sky="91d7e3")
+ self.assertIn(
+ "brand palette entry sky must be a hex color like #24273a",
+ validate_palette(invalid),
+ )
+
+ def test_facts_must_match_verified_metrics(self) -> None:
+ self.assertEqual(validate_facts(VALID_FACTS), [])
+
+ invalid_attempts = dict(VALID_FACTS, max_attempts_per_item=5)
+ self.assertTrue(any("max_attempts_per_item" in error for error in validate_facts(invalid_attempts)))
+
+ invalid_timeout = dict(VALID_FACTS, default_inactivity_timeout_seconds=30)
+ self.assertTrue(any("default_inactivity_timeout_seconds" in error for error in validate_facts(invalid_timeout)))
+
+ def test_rendered_brand_svg_assets_are_deterministic(self) -> None:
+ self.assertEqual(render_logo_svg(VALID_PALETTE, VALID_FACTS), render_logo_svg(VALID_PALETTE, VALID_FACTS))
+ self.assertEqual(render_favicon_svg(VALID_PALETTE, VALID_FACTS), render_favicon_svg(VALID_PALETTE, VALID_FACTS))
+ self.assertEqual(render_readme_banner_svg(VALID_PALETTE, VALID_FACTS), render_readme_banner_svg(VALID_PALETTE, VALID_FACTS))
+ self.assertEqual(render_social_preview_svg(VALID_PALETTE, VALID_FACTS), render_social_preview_svg(VALID_PALETTE, VALID_FACTS))
+
+ def test_rendered_brand_svg_assets_use_accepted_copy(self) -> None:
+ logo = render_logo_svg(VALID_PALETTE, VALID_FACTS)
+ self.assertIn(VALID_PALETTE["sky"], logo)
+ self.assertIn(VALID_PALETTE["text"], logo)
+
+ banner = render_readme_banner_svg(VALID_PALETTE, VALID_FACTS)
+ self.assertIn(VALID_FACTS["readme_banner_tagline"], banner)
+
+ def test_rendered_brand_assets_validate_social_preview_png(self) -> None:
+ assets = self.render_assets_without_rsvg()
+ self.assertEqual(validate_social_preview_png(assets["social-preview.png"]), [])
+
+ invalid_png = b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0D" + b"IHDR" + b"\x00\x00\x00\x05" + b"\x00\x00\x00\x03" + b"\x00" * 20
+ invalid_png_errors = validate_social_preview_png(invalid_png)
+ self.assertTrue(any("social preview PNG must be 1280x640" in error for error in invalid_png_errors))
+
+ def test_rendered_brand_assets_reject_invalid_data(self) -> None:
+ with patch("scripts.brand_generate.render_social_preview_png"):
+ with self.assertRaises(BrandError):
+ render_generated_assets(dict(VALID_PALETTE, sky="red"), VALID_FACTS)
+
+ def test_social_preview_renders_three_separate_pillar_lines(self) -> None:
+ social_preview = render_social_preview_svg(VALID_PALETTE, VALID_FACTS)
+ self.assertIn(VALID_FACTS["social_pillars"][0], social_preview)
+ self.assertIn(VALID_FACTS["social_pillars"][1], social_preview)
+ self.assertIn(VALID_FACTS["social_pillars"][2], social_preview)
+
+ def test_check_detects_missing_and_stale_generated_assets(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ root = Path(tmp_dir)
+ assets = self.render_assets_without_rsvg()
+ write_generated_assets(root, assets)
+
+ self.assertEqual(check_generated_assets(root, assets), [])
+
+ logo_path = root / "brand" / "generated" / "logo.svg"
+ logo_path.write_bytes(b"stale")
+ self.assertEqual(check_generated_assets(root, assets), ["stale generated asset: brand/generated/logo.svg"])
+
+ favicon_path = root / "brand" / "generated" / "favicon.svg"
+ favicon_path.unlink()
+ self.assertEqual(
+ check_generated_assets(root, assets),
+ [
+ "missing generated asset: brand/generated/favicon.svg",
+ "stale generated asset: brand/generated/logo.svg",
+ ],
+ )
+
+ def test_brand_check_uses_metadata_to_detect_stale_png_without_rendering(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ root = Path(tmp_dir)
+ data_dir = root / "brand" / "data"
+ data_dir.mkdir(parents=True)
+ (data_dir / "brand.json").write_text(json.dumps(VALID_PALETTE), encoding="utf-8")
+ (data_dir / "facts.json").write_text(json.dumps(VALID_FACTS), encoding="utf-8")
+ assets = self.render_assets_without_rsvg()
+ write_generated_assets(root, assets)
+
+ with patch("scripts.brand_generate.render_social_preview_png", side_effect=AssertionError("renderer called")):
+ self.assertEqual(check_brand_assets(root), [])
+ changed_facts = dict(VALID_FACTS, readme_banner_tagline="A changed tagline.")
+ (data_dir / "facts.json").write_text(json.dumps(changed_facts), encoding="utf-8")
+ self.assertEqual(
+ check_brand_assets(root),
+ [
+ "stale generated asset: brand/generated/readme-banner.svg",
+ "stale generated asset: brand/generated/social-preview.svg",
+ "stale generated metadata: brand/generated/social-preview.json source_sha256",
+ ],
+ )
+ (data_dir / "facts.json").write_text(json.dumps(VALID_FACTS), encoding="utf-8")
+ (root / "brand" / "generated" / "social-preview.png").write_bytes(self.valid_png() + b"stale")
+ self.assertEqual(
+ check_brand_assets(root),
+ ["stale generated metadata: brand/generated/social-preview.json png_sha256"],
+ )
+
+ @unittest.skipUnless(shutil.which("rsvg-convert"), "rsvg-convert is unavailable")
+ def test_rsvg_convert_integration(self) -> None:
+ svg = ''
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ svg_path = Path(tmp_dir) / "small.svg"
+ png_path = Path(tmp_dir) / "small.png"
+ svg_path.write_text(svg, encoding="utf-8")
+ subprocess.run(["rsvg-convert", "--output", str(png_path), str(svg_path)], check=True)
+ png_bytes = png_path.read_bytes()
+ self.assertEqual(png_bytes[:8], b"\x89PNG\r\n\x1a\n")
+ self.assertEqual((int.from_bytes(png_bytes[16:20], "big"), int.from_bytes(png_bytes[20:24], "big")), (2, 3))
+ self.assertGreater(len(png_bytes), 24)
+
+
+if __name__ == "__main__":
+ unittest.main()