c0a2953bea
gates / gates (push) Successful in 6m15s
The old card's lettering was plain white and in some other face. The card now takes assets/logo-wordmark.png - the same file the sidebar shows - scaled to the old lettering's ink height and alpha-composited in its place, so felhom is white, .eu is blue, and the drawn outline is there. The cloud mark beside it is still the old card's pixels, which were always right. og:image ?v=3.
155 lines
7.1 KiB
Python
155 lines
7.1 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)
|
|
# The old card's wordmark was plain white; the brand's is "felhom" white and ".eu" blue (operator,
|
|
# 2026-10-12). It is not a font we can set - it is drawn lettering with an outline - so the card
|
|
# takes assets/logo-wordmark.png, the same file the sidebar shows, instead of the old card's pixels.
|
|
MARK_X = (73, 153) # the cloud mark on the old card, which is unchanged
|
|
WORD_AT = (176, 89) # where the old card's lettering began, and where the real one goes
|
|
WORD_INK_H = 34 # its ink height there
|
|
|
|
|
|
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((MARK_X[0], LOGO_BAND[0], MARK_X[1], LOGO_BAND[1]), (MARK_X[0], LOGO_BAND[0]))
|
|
word = Image.open(os.path.join(A, "logo-wordmark.png")).convert("RGBA")
|
|
wb = word.getbbox() # the lettering's own edges inside its canvas
|
|
word = word.crop(wb)
|
|
ws = WORD_INK_H / float(word.size[1])
|
|
word = word.resize((int(round(word.size[0] * ws)), WORD_INK_H), Image.LANCZOS)
|
|
card.paste(word, WORD_AT, word)
|
|
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()
|