Skip to content

Commit 96cb09e

Browse files
fix(og): preserve unchanged generated images (#17)
1 parent bdacfee commit 96cb09e

2 files changed

Lines changed: 94 additions & 15 deletions

File tree

scripts/generate_og_images.py

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
from datetime import datetime
1818
from pathlib import Path
1919

20+
from PIL import Image, ImageChops
21+
2022
import yaml
21-
from PIL import Image, ImageDraw, ImageFilter, ImageFont
23+
from PIL import ImageDraw, ImageFilter, ImageFont
2224

2325
# ---------------------------------------------------------------------------
2426
# Constants
@@ -44,6 +46,30 @@
4446
CONFIG_PATH = ROOT / "_config.yml"
4547

4648

49+
def images_equal(path: Path, image: Image.Image) -> bool:
50+
"""Return True if path already contains the same rendered image."""
51+
if not path.exists():
52+
return False
53+
try:
54+
with Image.open(path) as existing:
55+
diff = ImageChops.difference(existing.convert("RGB"), image.convert("RGB"))
56+
return diff.getbbox() is None
57+
except OSError:
58+
return False
59+
60+
61+
def save_if_changed(
62+
img: Image.Image, output_path: Path, *, force: bool = False
63+
) -> bool:
64+
"""Save an OG image only when pixels changed; return True when written."""
65+
output_path.parent.mkdir(parents=True, exist_ok=True)
66+
rgb = img.convert("RGB")
67+
if not force and images_equal(output_path, rgb):
68+
return False
69+
rgb.save(output_path, "PNG", optimize=True)
70+
return True
71+
72+
4773
def load_font(path: str, size: int) -> ImageFont.FreeTypeFont:
4874
return ImageFont.truetype(path, size)
4975

@@ -131,8 +157,12 @@ def auto_title_size(title: str, draw: ImageDraw.ImageDraw,
131157

132158
def generate_post_og(title: str, date_str: str, tags: list[str],
133159
excerpt: str, hero_path: Path | None,
134-
output_path: Path, site_domain: str = "") -> None:
135-
"""Generate OG image for a blog post (Variant 1 or 2)."""
160+
output_path: Path, site_domain: str = "",
161+
*, force: bool = False) -> bool:
162+
"""Generate OG image for a blog post (Variant 1 or 2).
163+
164+
Returns True when the output file changed.
165+
"""
136166
img = Image.new("RGBA", (WIDTH, HEIGHT))
137167

138168
if hero_path and hero_path.exists():
@@ -263,12 +293,16 @@ def generate_post_og(title: str, date_str: str, tags: list[str],
263293
ey += (bbox_e[3] - bbox_e[1]) + 6
264294

265295
# Save
266-
output_path.parent.mkdir(parents=True, exist_ok=True)
267-
img.convert("RGB").save(output_path, "PNG", optimize=True)
296+
return save_if_changed(img, output_path, force=force)
297+
268298

299+
def generate_site_default(
300+
output_path: Path, tagline: str, *, force: bool = False
301+
) -> bool:
302+
"""Generate the site-default OG image (Variant 3).
269303
270-
def generate_site_default(output_path: Path, tagline: str) -> None:
271-
"""Generate the site-default OG image (Variant 3)."""
304+
Returns True when the output file changed.
305+
"""
272306
img = Image.new("RGBA", (WIDTH, HEIGHT))
273307
draw_diagonal_gradient(img, PRIMARY, DARK_BG)
274308

@@ -313,8 +347,7 @@ def generate_site_default(output_path: Path, tagline: str) -> None:
313347
draw.text((cx - tw2 // 2, accent_y + 26),
314348
tagline, fill=(255, 255, 255, 200), font=tagline_font)
315349

316-
output_path.parent.mkdir(parents=True, exist_ok=True)
317-
img.convert("RGB").save(output_path, "PNG", optimize=True)
350+
return save_if_changed(img, output_path, force=force)
318351

319352

320353
# ---------------------------------------------------------------------------
@@ -384,8 +417,8 @@ def main() -> None:
384417

385418
# --- Site default ---
386419
default_path = IMAGES_DIR / "og-default.png"
387-
print(f"Generating site default → {default_path.relative_to(ROOT)}")
388-
generate_site_default(default_path, tagline)
420+
print(f"Checking site default → {default_path.relative_to(ROOT)}")
421+
generate_site_default(default_path, tagline, force=args.force)
389422

390423
# --- Blog posts ---
391424
posts = sorted(POSTS_DIR.glob("*.md"))
@@ -415,10 +448,12 @@ def main() -> None:
415448
if hero_image:
416449
hero_path = ROOT / hero_image.lstrip("/")
417450

418-
print(f" [{generated + 1}] {slug}")
419-
generate_post_og(title, date_str, tags, excerpt, hero_path, output_path,
420-
site_domain)
421-
generated += 1
451+
if generate_post_og(title, date_str, tags, excerpt, hero_path, output_path,
452+
site_domain, force=args.force):
453+
generated += 1
454+
print(f" [{generated}] {slug}")
455+
else:
456+
skipped += 1
422457

423458
print(f"\nDone: {generated} generated, {skipped} skipped (up-to-date)")
424459

tests/test_generate_og_images.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
6+
from PIL import Image
7+
8+
9+
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate_og_images.py"
10+
spec = importlib.util.spec_from_file_location("generate_og_images", MODULE_PATH)
11+
assert spec and spec.loader
12+
og = importlib.util.module_from_spec(spec)
13+
spec.loader.exec_module(og)
14+
15+
16+
def test_save_if_changed_preserves_mtime_for_identical_pixels(tmp_path: Path) -> None:
17+
path = tmp_path / "og.png"
18+
image = Image.new("RGB", (16, 16), "purple")
19+
20+
assert og.save_if_changed(image, path) is True
21+
before = path.stat().st_mtime_ns
22+
23+
assert og.save_if_changed(image, path) is False
24+
assert path.stat().st_mtime_ns == before
25+
26+
27+
def test_save_if_changed_writes_when_pixels_change(tmp_path: Path) -> None:
28+
path = tmp_path / "og.png"
29+
assert og.save_if_changed(Image.new("RGB", (16, 16), "purple"), path) is True
30+
before = path.read_bytes()
31+
32+
assert og.save_if_changed(Image.new("RGB", (16, 16), "orange"), path) is True
33+
assert path.read_bytes() != before
34+
35+
36+
def test_save_if_changed_force_rewrites_identical_pixels(tmp_path: Path) -> None:
37+
path = tmp_path / "og.png"
38+
image = Image.new("RGB", (16, 16), "purple")
39+
40+
assert og.save_if_changed(image, path) is True
41+
before = path.stat().st_mtime_ns
42+
43+
assert og.save_if_changed(image, path, force=True) is True
44+
assert path.stat().st_mtime_ns >= before

0 commit comments

Comments
 (0)