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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + ffmpegfyMe + Download HTTP/HLS streams as MP4 or MKV — no transcoding. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Download HTTP/HLS streams as MP4 or MKV — no transcoding. + + + + + Authenticated HTTP/HLS + + + MP4/MKV stream copy + + + Resumable batches with retries + + 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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Download HTTP/HLS streams as MP4 or MKV — no transcoding. + Authenticated HTTP/HLS + MP4/MKV stream copy + Resumable batches with retries + 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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + ffmpegfyMe + {{TAGLINE}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{TAGLINE}} + + + + + {{PILLAR_1}} + + + {{PILLAR_2}} + + + {{PILLAR_3}} + + 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 @@ + + ffmpegfyMe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{TAGLINE}} + {{PILLAR_1}} + {{PILLAR_2}} + {{PILLAR_3}} + 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()