website: the share card shows the product, not just the words
gates / gates (push) Successful in 6m13s
gates / gates (push) Successful in 6m13s
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.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user