id: og-image-generator
namespace: company.team
description: Generate a branded 1200x630 Open Graph (social preview) card in
light and dark variants from a title, subtitle, kicker, footer and accent
color - on demand or by webhook.
inputs:
- id: title
type: STRING
defaults: "Ship release notes people actually read"
description: The headline rendered large on the card. Ships with a sample so the
flow runs as-is; a webhook body field with the same name overrides it.
- id: subtitle
type: STRING
defaults: "Automated changelogs from merged PRs, in your brand voice"
description: One or two lines of supporting text under the title.
- id: kicker
type: STRING
defaults: "Changelog"
description: Small uppercase label above the title (for example RELEASE,
CHANGELOG, PODCAST).
- id: footer
type: STRING
defaults: "github.com/acme/rocket"
description: Bottom line, typically a repo, domain or handle.
- id: accent
type: STRING
defaults: "#7C5CFF"
description: Brand accent color as a hex value, used for the left bar and kicker.
- id: slack_notify
type: BOOL
defaults: false
description: When true, announce the finished cards in Slack (needs the
SLACK_WEBHOOK_URL secret).
variables:
card_title: "{{ trigger.body.title ?? inputs.title }}"
card_subtitle: "{{ trigger.body.subtitle ?? inputs.subtitle }}"
card_kicker: "{{ trigger.body.kicker ?? inputs.kicker }}"
card_footer: "{{ trigger.body.footer ?? inputs.footer }}"
card_accent: "{{ trigger.body.accent ?? inputs.accent }}"
tasks:
- id: validate_title
type: io.kestra.plugin.core.flow.If
condition: "{{ variables.card_title | trim | length == 0 }}"
then:
- id: fail_empty_title
type: io.kestra.plugin.core.execution.Fail
errorMessage: "og-image-generator: `title` is empty. Pass it as a flow input or
as a `title` field in the webhook JSON body."
- id: render_variants
type: io.kestra.plugin.core.flow.Parallel
tasks:
- id: render_light
type: io.kestra.plugin.scripts.python.Script
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.13-slim
dependencies:
- pillow
outputFiles:
- og-card-light.png
env:
TITLE: "{{ variables.card_title }}"
SUBTITLE: "{{ variables.card_subtitle }}"
KICKER: "{{ variables.card_kicker }}"
FOOTER: "{{ variables.card_footer }}"
ACCENT: "{{ variables.card_accent }}"
THEME: light
OUT: og-card-light.png
script: |
"""Render a 1200x630 Open Graph card. Reads TITLE, SUBTITLE, KICKER, FOOTER,
ACCENT, THEME, OUT from the environment. Exits 1 on an empty title."""
import os
import sys
import urllib.request
from PIL import Image, ImageDraw, ImageFont
W, H = 1200, 630
SS = 2 # supersample factor
TITLE = (os.environ.get("TITLE") or "").strip()
if not TITLE:
sys.exit("TITLE must not be empty")
SUBTITLE = (os.environ.get("SUBTITLE") or "").strip()
KICKER = (os.environ.get("KICKER") or "").strip()
FOOTER = (os.environ.get("FOOTER") or "").strip()
ACCENT = (os.environ.get("ACCENT") or "#7C5CFF").strip()
THEME = (os.environ.get("THEME") or "light").strip().lower()
OUT = os.environ.get("OUT") or f"og-card-{THEME}.png"
MAX_TITLE_CHARS = 220
PALETTES = {
"light": {"bg": "#FBFAF7", "fg": "#17181C", "muted": "#5B5E66", "faint": "#E7E5DF"},
"dark": {"bg": "#0E1014", "fg": "#F5F4F0", "muted": "#9BA0AA", "faint": "#23262E"},
}
if THEME not in PALETTES:
sys.exit(f"THEME must be one of {sorted(PALETTES)}, got {THEME!r}")
PAL = PALETTES[THEME]
FONT_URL = (
"https://raw.githubusercontent.com/google/fonts/main/ofl/inter/"
"Inter%5Bopsz%2Cwght%5D.ttf"
)
def load_font(size_px: int, weight: int) -> ImageFont.FreeTypeFont:
"""Inter variable (downloaded once), else DejaVu, else PIL default."""
path = "/tmp/Inter-Variable.ttf"
if not os.path.exists(path):
try:
urllib.request.urlretrieve(FONT_URL, path)
except Exception:
path = None # type: ignore[assignment]
if path:
try:
f = ImageFont.truetype(path, size_px)
f.set_variation_by_axes([18, weight])
return f
except Exception:
pass
for cand in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
if weight >= 600
else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
):
try:
return ImageFont.truetype(cand, size_px)
except Exception:
continue
return ImageFont.load_default(size_px)
def hex_rgb(h: str):
h = h.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
sys.exit(f"ACCENT must be a hex color like #7C5CFF, got {ACCENT!r}")
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
def wrap(draw, text, font, max_w):
"""Greedy word wrap; returns list of lines."""
words = text.split()
lines, cur = [], ""
for w in words:
trial = (cur + " " + w).strip()
if draw.textlength(trial, font=font) <= max_w:
cur = trial
else:
if cur:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines
def ellipsize(draw, line, font, max_w):
while line and draw.textlength(line + "…", font=font) > max_w:
line = line[:-1]
return line.rstrip() + "…" if line else "…"
def fit(draw, text, weight, start, floor, max_w, max_lines):
"""Largest size in [floor, start] whose wrap fits max_lines."""
size = start
while size > floor:
font = load_font(size, weight)
lines = wrap(draw, text, font, max_w)
if len(lines) <= max_lines:
return font, lines
size -= 6
font = load_font(floor, weight)
lines = wrap(draw, text, font, max_w)[:max_lines]
lines[-1] = ellipsize(draw, lines[-1], font, max_w)
return font, lines
def draw_tracked(draw, xy, text, font, fill, tracking):
x, y = xy
for ch in text:
draw.text((x, y), ch, font=font, fill=fill)
x += draw.textlength(ch, font=font) + tracking
return x
def main():
accent = hex_rgb(ACCENT)
img = Image.new("RGB", (W * SS, H * SS), PAL["bg"])
d = ImageDraw.Draw(img)
M = 96 * SS
# Full-height accent bar on the left edge.
d.rectangle([0, 0, 16 * SS, H * SS], fill=accent)
# Quiet dot grid in the free corner below the footer rule.
dot = hex_rgb(PAL["faint"])
for gy in range(H * SS - 88 * SS, H * SS - 24 * SS, 26 * SS):
for gx in range(W * SS - 200 * SS, W * SS - 44 * SS, 26 * SS):
d.ellipse([gx, gy, gx + 4 * SS, gy + 4 * SS], fill=dot)
max_w = W * SS - M - 80 * SS
y = 118 * SS
if KICKER:
kf = load_font(24 * SS, 600)
d.rectangle([M, y + 4 * SS, M + 12 * SS, y + 16 * SS], fill=accent)
y_end = draw_tracked(d, (M + 26 * SS, y), KICKER.upper(), kf, accent, 3 * SS)
y += 54 * SS
tf, tlines = fit(d, TITLE[:MAX_TITLE_CHARS], 700, 86 * SS, 52 * SS, max_w, 3)
for line in tlines:
d.text((M, y), line, font=tf, fill=hex_rgb(PAL["fg"]))
y += int((tf.size + 16 * SS))
y += 34 * SS
if SUBTITLE:
sf, slines = fit(d, SUBTITLE, 400, 38 * SS, 30 * SS, max_w, 2)
for line in slines:
d.text((M, y), line, font=sf, fill=hex_rgb(PAL["muted"]))
y += int((sf.size + 12 * SS))
# Footer rule + text, pinned to the bottom.
fy = H * SS - 108 * SS
d.rectangle([M, fy, W * SS - 96 * SS, fy + 2 * SS], fill=hex_rgb(PAL["faint"]))
if FOOTER:
ff = load_font(27 * SS, 500)
d.text((M, fy + 24 * SS), FOOTER, font=ff, fill=hex_rgb(PAL["muted"]))
img = img.resize((W, H), Image.LANCZOS)
img.save(OUT, "PNG")
print(f"wrote {OUT}")
if __name__ == "__main__":
main()
- id: render_dark
type: io.kestra.plugin.scripts.python.Script
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.13-slim
dependencies:
- pillow
outputFiles:
- og-card-dark.png
env:
TITLE: "{{ variables.card_title }}"
SUBTITLE: "{{ variables.card_subtitle }}"
KICKER: "{{ variables.card_kicker }}"
FOOTER: "{{ variables.card_footer }}"
ACCENT: "{{ variables.card_accent }}"
THEME: dark
OUT: og-card-dark.png
script: |
"""Render a 1200x630 Open Graph card. Reads TITLE, SUBTITLE, KICKER, FOOTER,
ACCENT, THEME, OUT from the environment. Exits 1 on an empty title."""
import os
import sys
import urllib.request
from PIL import Image, ImageDraw, ImageFont
W, H = 1200, 630
SS = 2 # supersample factor
TITLE = (os.environ.get("TITLE") or "").strip()
if not TITLE:
sys.exit("TITLE must not be empty")
SUBTITLE = (os.environ.get("SUBTITLE") or "").strip()
KICKER = (os.environ.get("KICKER") or "").strip()
FOOTER = (os.environ.get("FOOTER") or "").strip()
ACCENT = (os.environ.get("ACCENT") or "#7C5CFF").strip()
THEME = (os.environ.get("THEME") or "light").strip().lower()
OUT = os.environ.get("OUT") or f"og-card-{THEME}.png"
MAX_TITLE_CHARS = 220
PALETTES = {
"light": {"bg": "#FBFAF7", "fg": "#17181C", "muted": "#5B5E66", "faint": "#E7E5DF"},
"dark": {"bg": "#0E1014", "fg": "#F5F4F0", "muted": "#9BA0AA", "faint": "#23262E"},
}
if THEME not in PALETTES:
sys.exit(f"THEME must be one of {sorted(PALETTES)}, got {THEME!r}")
PAL = PALETTES[THEME]
FONT_URL = (
"https://raw.githubusercontent.com/google/fonts/main/ofl/inter/"
"Inter%5Bopsz%2Cwght%5D.ttf"
)
def load_font(size_px: int, weight: int) -> ImageFont.FreeTypeFont:
"""Inter variable (downloaded once), else DejaVu, else PIL default."""
path = "/tmp/Inter-Variable.ttf"
if not os.path.exists(path):
try:
urllib.request.urlretrieve(FONT_URL, path)
except Exception:
path = None # type: ignore[assignment]
if path:
try:
f = ImageFont.truetype(path, size_px)
f.set_variation_by_axes([18, weight])
return f
except Exception:
pass
for cand in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
if weight >= 600
else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
):
try:
return ImageFont.truetype(cand, size_px)
except Exception:
continue
return ImageFont.load_default(size_px)
def hex_rgb(h: str):
h = h.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
sys.exit(f"ACCENT must be a hex color like #7C5CFF, got {ACCENT!r}")
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
def wrap(draw, text, font, max_w):
"""Greedy word wrap; returns list of lines."""
words = text.split()
lines, cur = [], ""
for w in words:
trial = (cur + " " + w).strip()
if draw.textlength(trial, font=font) <= max_w:
cur = trial
else:
if cur:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines
def ellipsize(draw, line, font, max_w):
while line and draw.textlength(line + "…", font=font) > max_w:
line = line[:-1]
return line.rstrip() + "…" if line else "…"
def fit(draw, text, weight, start, floor, max_w, max_lines):
"""Largest size in [floor, start] whose wrap fits max_lines."""
size = start
while size > floor:
font = load_font(size, weight)
lines = wrap(draw, text, font, max_w)
if len(lines) <= max_lines:
return font, lines
size -= 6
font = load_font(floor, weight)
lines = wrap(draw, text, font, max_w)[:max_lines]
lines[-1] = ellipsize(draw, lines[-1], font, max_w)
return font, lines
def draw_tracked(draw, xy, text, font, fill, tracking):
x, y = xy
for ch in text:
draw.text((x, y), ch, font=font, fill=fill)
x += draw.textlength(ch, font=font) + tracking
return x
def main():
accent = hex_rgb(ACCENT)
img = Image.new("RGB", (W * SS, H * SS), PAL["bg"])
d = ImageDraw.Draw(img)
M = 96 * SS
# Full-height accent bar on the left edge.
d.rectangle([0, 0, 16 * SS, H * SS], fill=accent)
# Quiet dot grid in the free corner below the footer rule.
dot = hex_rgb(PAL["faint"])
for gy in range(H * SS - 88 * SS, H * SS - 24 * SS, 26 * SS):
for gx in range(W * SS - 200 * SS, W * SS - 44 * SS, 26 * SS):
d.ellipse([gx, gy, gx + 4 * SS, gy + 4 * SS], fill=dot)
max_w = W * SS - M - 80 * SS
y = 118 * SS
if KICKER:
kf = load_font(24 * SS, 600)
d.rectangle([M, y + 4 * SS, M + 12 * SS, y + 16 * SS], fill=accent)
y_end = draw_tracked(d, (M + 26 * SS, y), KICKER.upper(), kf, accent, 3 * SS)
y += 54 * SS
tf, tlines = fit(d, TITLE[:MAX_TITLE_CHARS], 700, 86 * SS, 52 * SS, max_w, 3)
for line in tlines:
d.text((M, y), line, font=tf, fill=hex_rgb(PAL["fg"]))
y += int((tf.size + 16 * SS))
y += 34 * SS
if SUBTITLE:
sf, slines = fit(d, SUBTITLE, 400, 38 * SS, 30 * SS, max_w, 2)
for line in slines:
d.text((M, y), line, font=sf, fill=hex_rgb(PAL["muted"]))
y += int((sf.size + 12 * SS))
# Footer rule + text, pinned to the bottom.
fy = H * SS - 108 * SS
d.rectangle([M, fy, W * SS - 96 * SS, fy + 2 * SS], fill=hex_rgb(PAL["faint"]))
if FOOTER:
ff = load_font(27 * SS, 500)
d.text((M, fy + 24 * SS), FOOTER, font=ff, fill=hex_rgb(PAL["muted"]))
img = img.resize((W, H), Image.LANCZOS)
img.save(OUT, "PNG")
print(f"wrote {OUT}")
if __name__ == "__main__":
main()
- id: share_to_slack
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.slack_notify }}"
then:
- id: slack_announce
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |-
*OG cards ready* - {{ variables.card_title }}
Light and dark 1200x630 PNGs are attached to this execution's outputs.
outputs:
- id: light_card
type: FILE
value: "{{ outputs.render_light.outputFiles['og-card-light.png'] }}"
- id: dark_card
type: FILE
value: "{{ outputs.render_dark.outputFiles['og-card-dark.png'] }}"
triggers:
- id: on_request
type: io.kestra.plugin.core.trigger.Webhook
key: "{{ secret('OG_WEBHOOK_KEY') }}"
disabled: true
description: |-
Disabled by default so nothing public is exposed before you are ready. To accept card
requests over HTTP, add an OG_WEBHOOK_KEY secret to the namespace and set disabled: false.
POST JSON like {"title": "...", "subtitle": "...", "kicker": "...", "footer": "...",
"accent": "#RRGGBB"} - any missing field falls back to the flow input of the same name.