Git hosting and a container registry in one Rust binary (axum + Astro)
Logo: silver ingot (transparent PNG source); generator accepts transparent sources
10 files changed, +40 -11
+1-1backend/src/web/ui.rs
| @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; | ||
| 4 | 4 | use maud::{Markup, PreEscaped, html}; |
| 5 | 5 | use sha2::{Digest, Sha256}; |
| 6 | 6 | |
| 7 | -/// The stacked-bars mark (frontend/public/logo-128.png, built by | |
| 7 | +/// The ingot mark (frontend/public/logo-128.png, built by | |
| 8 | 8 | /// scripts/gen-brand.py), sharp up to about 40 px on 3x screens. |
| 9 | 9 | pub fn logo(size: u32) -> Markup { |
| 10 | 10 | html! { |
+0-0brand/logo-source.jpg
Binary file changed.
+0-0brand/logo-source.png
Binary file changed.
+0-0frontend/public/apple-touch-icon.png
Binary file changed.
+0-0frontend/public/favicon-32.png
Binary file changed.
+0-0frontend/public/favicon.ico
Binary file changed.
+0-0frontend/public/icon-192.png
Binary file changed.
+0-0frontend/public/logo-128.png
Binary file changed.
+0-0frontend/public/logo.png
Binary file changed.
+39-10scripts/gen-brand.py
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | -"""Builds the irongit icons from brand/logo-source.jpg (the stacked bars on a | |
| 3 | -flat green background). | |
| 2 | +"""Builds the irongit icons from the logo in brand/. | |
| 4 | 3 | |
| 5 | -The background is keyed out by how green each pixel is; edge pixels are | |
| 6 | -unmixed (P = a*F + (1-a)*G solved for a and F) so the outline keeps its real | |
| 7 | -colour instead of a green fringe. Output goes to frontend/public: | |
| 4 | +brand/logo-source.png is used as is when it already has a transparent | |
| 5 | +background; near-opaque pixels are snapped to fully opaque so the mark never | |
| 6 | +lets a background tint through. A source on a flat green screen (PNG without | |
| 7 | +alpha, or brand/logo-source.jpg) is keyed instead: the green is removed by how | |
| 8 | +green each pixel is, and edge pixels are unmixed (P = a*F + (1-a)*G solved for | |
| 9 | +a and F) so outlines keep their real colour instead of a green fringe. | |
| 10 | + | |
| 11 | +Output goes to frontend/public: | |
| 8 | 12 | |
| 9 | 13 | logo.png 512 px, transparent, for pages and Open Graph |
| 10 | 14 | logo-128.png the nav and auth pages (crisp up to 3x at ~40 px) |
| @@ -23,7 +27,7 @@ import numpy as np | ||
| 23 | 27 | from PIL import Image |
| 24 | 28 | |
| 25 | 29 | ROOT = Path(__file__).resolve().parent.parent |
| 26 | -SOURCE = ROOT / "brand" / "logo-source.jpg" | |
| 30 | +BRAND = ROOT / "brand" | |
| 27 | 31 | OUT = ROOT / "frontend" / "public" |
| 28 | 32 | SURFACE = (11, 15, 22) # --color-surface, dark theme |
| 29 | 33 | |
| @@ -53,9 +57,24 @@ def key_out_green(path: Path) -> Image.Image: | ||
| 53 | 57 | fg[..., 1] = np.minimum(fg[..., 1], spill_cap) |
| 54 | 58 | |
| 55 | 59 | rgba = np.dstack([fg, alpha * 255]).round().astype(np.uint8) |
| 56 | - image = Image.fromarray(rgba, "RGBA") | |
| 60 | + print(f"keyed out background {bg.round().astype(int).tolist()}") | |
| 61 | + return Image.fromarray(rgba, "RGBA") | |
| 62 | + | |
| 63 | + | |
| 64 | +def load_transparent(path: Path) -> Image.Image: | |
| 65 | + rgba = np.asarray(Image.open(path).convert("RGBA")).copy() | |
| 66 | + alpha = rgba[..., 3] | |
| 67 | + # Background removers leave the body at 250-254; make it solid. | |
| 68 | + alpha[alpha >= 245] = 255 | |
| 69 | + rgba[..., 3] = alpha | |
| 70 | + rgba[alpha == 0, :3] = 0 | |
| 71 | + print(f"transparent source, {int((alpha == 255).sum())} opaque pixels") | |
| 72 | + return Image.fromarray(rgba, "RGBA") | |
| 57 | 73 | |
| 58 | - # Crop to the ink and square it up with transparent padding. | |
| 74 | + | |
| 75 | +def squared(image: Image.Image) -> Image.Image: | |
| 76 | + """Crops to the ink and squares it up with transparent padding.""" | |
| 77 | + alpha = np.asarray(image)[..., 3] / 255.0 | |
| 59 | 78 | ys, xs = np.where(alpha > 0.5) |
| 60 | 79 | x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1 |
| 61 | 80 | image = image.crop((x0, y0, x1, y1)) |
| @@ -63,7 +82,7 @@ def key_out_green(path: Path) -> Image.Image: | ||
| 63 | 82 | pad = round(side * 0.02) |
| 64 | 83 | square = Image.new("RGBA", (side + 2 * pad, side + 2 * pad), (0, 0, 0, 0)) |
| 65 | 84 | square.paste(image, ((square.width - image.width) // 2, (square.height - image.height) // 2), image) |
| 66 | - print(f"background {bg.round().astype(int).tolist()}, artwork {x1 - x0}x{y1 - y0}, square {square.width}px") | |
| 85 | + print(f"artwork {x1 - x0}x{y1 - y0}, square {square.width}px") | |
| 67 | 86 | return square |
| 68 | 87 | |
| 69 | 88 | |
| @@ -82,8 +101,18 @@ def resized(image: Image.Image, size: int) -> Image.Image: | ||
| 82 | 101 | return despill(image.resize((size, size), Image.Resampling.LANCZOS)) |
| 83 | 102 | |
| 84 | 103 | |
| 104 | +def load_master() -> Image.Image: | |
| 105 | + png, jpg = BRAND / "logo-source.png", BRAND / "logo-source.jpg" | |
| 106 | + if png.exists(): | |
| 107 | + image = Image.open(png) | |
| 108 | + if image.mode in ("RGBA", "LA") and np.asarray(image.convert("RGBA"))[0, 0, 3] == 0: | |
| 109 | + return squared(load_transparent(png)) | |
| 110 | + return squared(key_out_green(png)) | |
| 111 | + return squared(key_out_green(jpg)) | |
| 112 | + | |
| 113 | + | |
| 85 | 114 | def main() -> int: |
| 86 | - master = key_out_green(SOURCE) | |
| 115 | + master = load_master() | |
| 87 | 116 | OUT.mkdir(parents=True, exist_ok=True) |
| 88 | 117 | resized(master, 512).save(OUT / "logo.png", optimize=True) |
| 89 | 118 | resized(master, 128).save(OUT / "logo-128.png", optimize=True) |