Files
felhom.eu/scripts/make_og_card.py
T
admin d328147322
gates / gates (push) Successful in 6m13s
website: the share card shows the product, not just the words
The operator went to post the link and found the card out of date: it was the
2026-07-18 design - a headline and two lines of text on a dark field, nothing of
the product, and older than everything the site has become since.

- og-image.png and og-image-en.png rebuilt around the laptop, with the hero's
  own poster frame on its screen.
- scripts/make_og_card.py: the headline, logo and button are lifted as PIXELS
  from the previous card and composited with `lighter` onto flat --bg-0, because
  the site's font is a woff2 and nothing here can draw a glyph with it. A new
  headline therefore CANNOT be made with this script - new words go back to the
  design tool and this takes that card as its source. The lede lines are gone;
  the laptop says what they said.
- ?v=2 on every og:image and twitter:image (20 pages): a scraper caches by URL,
  so a new picture at the old URL would reach nobody. A card already attached to
  a post still does not change - that post must drop the preview and re-add the
  link after a re-scrape.
2026-10-12 14:48:44 +02:00

143 lines
6.2 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""make_og_card.py - rebuild the link-preview cards (og:image) around the laptop.
python scripts/make_og_card.py [--lang hu|en|both] [--check]
WHAT IT IS FOR. The card Facebook, Messenger, LinkedIn and Slack show when somebody shares
felhom.eu. The 2026-07-18 cards were a headline and two lines of text on a dark field: they showed
the words and none of the product, and they predate the site's current look (operator, 2026-10-12:
"the image is outdated"). This puts the same laptop the home page shows - the frame with the real
screen recording's first frame in it - beside the headline.
HOW IT KEEPS THE TYPOGRAPHY. The site's font is a woff2 (Plus Jakarta Sans); no font file on this
machine or on DooPlex can be given to a drawing library, and installing one to draw two lines of text
would be a new dependency for a once-a-year picture. So the headline, the logo and the button are
taken as PIXELS from the previous card, which was rendered with the real font: they are cropped,
the headline is scaled to free the right half, and they are composited onto a flat --bg-0 field with
`lighter`, which makes the crop's own background disappear into the new one (both are #0A1220, so
there is no seam to see). Nothing here draws a glyph.
IF THE WORDS EVER CHANGE this script cannot help - a new headline needs the font, so the card goes
back to the design tool, and this script then takes the new card as its source.
INPUT website/assets/og-image.png, og-image-en.png (the previous cards - the typography)
website/assets/hero-laptop-frame.webp (the laptop, screen cut to transparency)
website/assets/hero-poster.webp (what the hero's clip opens on)
OUTPUT the same two card files, 1200x630, overwritten.
--check prints where the ink sits on the current cards and changes nothing.
"""
import argparse
import os
import sys
from PIL import Image, ImageChops
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
A = os.path.join(ROOT, "website", "assets")
W, H = 1200, 630
BG = (10, 18, 32) # --bg-0, which is what sits behind the headline on the old cards
TOPBAR_H = 6
LEFT = 73 # the logo's own left edge on the old cards
HEAD_TOP = 206 # where the headline's ink starts on the new card
HEAD_WIDTH = 498 # how wide the headline is allowed to be, to leave the laptop its half
LAPTOP_W = 548
LAPTOP_X = 616
# The screen hole in hero-laptop-frame.webp, measured off its alpha channel (2026-10-12):
HOLE = (0.11041, 0.12345, 0.79817, 0.64305)
CARDS = {
# lang: (file, headline band, pill band) - bands are y ranges on the OLD card, generous enough
# to hold accents and descenders
"hu": ("og-image.png", (200, 372), (510, 572)),
"en": ("og-image-en.png", (208, 382), (510, 572)),
}
LOGO_BAND = (76, 132)
def ink_bbox(im, y0, y1, thresh=300):
"""The bounding box of everything brighter than the background in a horizontal band."""
px = im.convert("RGB").load()
xs, ys = [], []
for y in range(y0, y1 + 1):
for x in range(im.size[0]):
if sum(px[x, y]) > thresh:
xs.append(x)
ys.append(y)
if not xs:
sys.exit("no ink in rows %d..%d - the source card is not the one this script expects" % (y0, y1))
return min(xs), min(ys), max(xs) + 1, max(ys) + 1
def laptop():
"""The frame with the hero's poster frame inside its cut-out screen."""
frame = Image.open(os.path.join(A, "hero-laptop-frame.webp")).convert("RGBA")
shot = Image.open(os.path.join(A, "hero-poster.webp")).convert("RGB")
fw, fh = frame.size
x, y = int(round(HOLE[0] * fw)), int(round(HOLE[1] * fh))
w, h = int(round(HOLE[2] * fw)), int(round(HOLE[3] * fh))
out = Image.new("RGBA", frame.size, (0, 0, 0, 0))
out.paste(shot.resize((w, h), Image.LANCZOS), (x, y))
out.alpha_composite(frame)
return out
def build(lang, check):
src_name, head_band, pill_band = CARDS[lang]
src_path = os.path.join(A, src_name)
src = Image.open(src_path).convert("RGB")
if src.size != (W, H):
sys.exit("%s is %dx%d, expected %dx%d" % (src_name, src.size[0], src.size[1], W, H))
logo = ink_bbox(src, *LOGO_BAND)
head = ink_bbox(src, *head_band)
pill = ink_bbox(src, *pill_band)
if check:
print("%s logo %s headline %s button %s" % (src_name, logo, head, pill))
return
card = Image.new("RGB", (W, H), BG)
card.paste(src.crop((0, 0, W, TOPBAR_H)), (0, 0)) # the blue rule along the top
lap = laptop()
lh = int(round(LAPTOP_W * lap.size[1] / float(lap.size[0])))
card.paste(lap.resize((LAPTOP_W, lh), Image.LANCZOS), (LAPTOP_X, (H - lh) // 2),
lap.resize((LAPTOP_W, lh), Image.LANCZOS))
# the three text blocks, lifted as pixels and laid down with `lighter` so their own
# background (the same #0A1220) vanishes instead of leaving a rectangle
def lay(box, at, scale=1.0):
part = src.crop(box)
if scale != 1.0:
part = part.resize((max(1, int(round(part.size[0] * scale))),
max(1, int(round(part.size[1] * scale)))), Image.LANCZOS)
region = card.crop((at[0], at[1], at[0] + part.size[0], at[1] + part.size[1]))
card.paste(ImageChops.lighter(region, part), at)
lay(logo, (LEFT, logo[1]))
scale = HEAD_WIDTH / float(head[2] - head[0])
lay(head, (LEFT, HEAD_TOP), scale)
lay(pill, (LEFT - 1, pill[1]))
card.save(src_path, "PNG", optimize=True)
print("%-16s %4dx%d %6.1f KB (headline scaled %.2f, laptop %dpx)"
% (src_name, W, H, os.path.getsize(src_path) / 1024.0, scale, LAPTOP_W))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--lang", default="both", choices=["hu", "en", "both"])
ap.add_argument("--check", action="store_true", help="print the measured bands, change nothing")
a = ap.parse_args()
for lang in (["hu", "en"] if a.lang == "both" else [a.lang]):
build(lang, a.check)
if not a.check:
print("Bump the ?v= on the og:image / twitter:image URLs, then re-scrape the page in "
"Facebook's Sharing Debugger - a card already attached to a post does not change.")
if __name__ == "__main__":
main()