OpenFresco
Server-side OG-image / social-card scenes, built to run on Fresco canvases.
An OG template is a design with holes — the post title, hero image, and CTA label are substituted per post (and per locale) at render time, not baked into the design. OpenFresco models that as a scene of positioned elements whose text/image values can be placeholders, resolves those values, and emits the result.
It renders to SVG (render_svg/3) or PNG (render/3, via the
optional :resvg dependency). Both come from one SVG generator, so the
editor preview and the final raster match by construction — "what you edit is
what renders."
Install
def deps do
[{:open_fresco, "~> 0.1"}]
end
Only runtime dependency is :jason. It does not depend on fresco to
render — a scene is standalone (it's merely carried inside a Fresco
canvas's extensions["open_fresco"] blob for editing/storage).
Quick start
alias OpenFresco.Scene
scene =
Scene.new(width: 1200, height: 630, background: Scene.solid("#0b1220"))
# Hero image behind everything (a placeholder — the real src is passed at render time)
|> Scene.add(Scene.image("hero",
box: %{x: 0, y: 0, w: 1200, h: 360},
value: Scene.placeholder("hero"), fit: :cover))
# A gradient scrim so the title stays legible over the photo
|> Scene.add(Scene.shape("scrim",
box: %{x: 0, y: 120, w: 1200, h: 510},
fill: Scene.gradient(90, [
%{offset: 0, color: "#0b1220", alpha: 0},
%{offset: 1, color: "#0b1220", alpha: 1}])))
# The title (a placeholder)
|> Scene.add(Scene.text("title",
box: %{x: 64, y: 400, w: 1072, h: 150},
value: Scene.placeholder("title"),
size: 72, weight: 700, fill: Scene.solid("#ffffff")))
# A CTA button (label is a placeholder — resolves per locale)
|> Scene.add(Scene.button("cta",
box: %{x: 64, y: 540, w: 240, h: 60},
label: Scene.placeholder("cta"),
fill: Scene.solid("#2563eb"), text_fill: Scene.solid("#ffffff")))
svg =
OpenFresco.render_svg(scene, %{
"hero" => "data:image/png;base64,…",
"title" => "Shipping open_fresco v0.1",
"cta" => "Read more"
})
Render the same scene with different values (e.g. another locale's CTA)
to get another card — the scene is the template, values are the holes.
Scene model
A scene is a fixed-size canvas (default 1200×630) plus a z-ordered list of
elements. Elements are plain maps with a :type, a :box
(%{x, y, w, h}), an :id, and a :z. Build them with the constructors:
| Constructor | Element | Key opts |
|---|---|---|
Scene.text/2 | text block | :value, :size, :weight, :font, :fill, :align, :line_height |
Scene.image/2 | positioned image | :value, :fit (:cover/:contain/:stretch), :radius |
Scene.shape/2 | filled (rounded) rect | :fill, :radius |
Scene.button/2 | shape + centered label | :label, :preset (:solid/:outline/:soft), :fill, :text_fill, :radius |
Fills
Any fillable surface (background, a shape/button fill, a text fill)
takes one of:
Scene.solid("#0b1220")
Scene.gradient(90, [ # angle° clockwise from →
%{offset: 0.0, color: "#000000", alpha: 0.0}, # per-stop alpha
%{offset: 1.0, color: "#000000", alpha: 0.8}
])
Scene.image_fill(Scene.placeholder("hero"), :cover) # image as a fill
Fills are tagged maps, so an editor's Image | Color | Gradient tab UI can round-trip the inactive variants' stored values.
Text: wrap, valign, underlay
Text word-wraps to its box width (plus explicit \n breaks) and
vertically aligns via :valign (:top / :middle / :bottom). An
:underlay (opacity > 0) draws a soft glow behind text for legibility over
busy backgrounds; shapes/images get a translucent box instead. A background
image fill takes an :overlay_opacity tint (the hero-darkening trick).
Scene.text("title",
box: %{x: 64, y: 400, w: 1072, h: 160},
value: Scene.placeholder("title"),
size: 72, valign: :bottom,
underlay_opacity: 0.5, underlay_color: :dark)
Scene.image_fill(Scene.placeholder("hero"), :cover, overlay_opacity: 0.4)
Anchoring, auto-width & masks
An element can be anchored to another — it repositions relative to the target's rendered box, so an anchored subtitle/CTA follows a title that wraps to more lines:
Scene.text("subtitle", box: %{x: 64, y: 0, w: 900, h: 44},
value: Scene.placeholder("subtitle"),
anchor: Scene.anchor("title", :below, gap: 16, align: :start))
Scene.button("cta", auto_width: true, padding: 28, # width from the label
label: Scene.placeholder("cta"),
anchor: Scene.anchor("subtitle", :below, gap: 24))
Scene.image("hero", value: Scene.placeholder("hero"), # fade into the bg
mask: Scene.gradient(90, [%{offset: 0, color: "#000", alpha: 1},
%{offset: 1, color: "#000", alpha: 0}]))
Edges are :below / :above / :left / :right; chains resolve in order
and cycles are rejected. Anchor reflow and wrap use real text measurement
on the PNG path (resvg's own engine, so wrap points match the render);
render_svg/3 uses a fast character estimate unless you pass measure: true.
Responsive sizing (one scene, any canvas)
Position elements with Scene.place/1 (canvas-relative insets) instead of
fixed coordinates, and the same template adapts to any canvas size:
Scene.button("cta", place: Scene.place(left: 64, bottom: 56, h: 54), …) # pinned bottom-left
Scene.text("title", place: Scene.place(left: 64, right: 64), …) # full-width, anchored above the CTA
# Render the same scene at any target:
scene = OpenFresco.Sizes.put(scene, :square) # :og :twitter :square :pinterest :story
{:ok, png, _} = OpenFresco.render(scene, values)
# or a custom size:
scene = Scene.put_size(scene, 1080, 1920)
Per axis: two insets stretch (w = width - left - right), one inset + size
pins to that edge, size-only centers, neither keeps the :box. :place
composes with :anchor — the placement sets size/cross-axis, the anchor
drives the flow axis — so a bottom-anchored content stack grows upward and
survives landscape, square, and portrait from one template.
Most sites just need the one 1200×630 (
:og) image — platforms scale/crop it. The presets are for when you want a platform-optimized variant.
Values, slots & globals
Two token styles, substituted inline inside text and image strings
(byte-compatible with phoenix_kit_og):
"{{title}} — [[site_name]]" # a slot and a global, mixed in one string
Scene.placeholder("title") # whole-value shorthand for "{{title}}"
OpenFresco.render_svg(scene,
%{"title" => "Hello", "hero" => "data:…"}, # {{slot}} values
globals: %{"site_name" => "Acme"}) # [[global]] values
{{slot}}— a template-local slot, wired per render.[[global]]— resolves from the:globalsmap (site host, page URL, locale). Locale is just a render input: pass the locale-resolved values (or a[[page_locale]]global), and two locales are tworender_svg/3calls.- An unwired
{{slot}}stays visible in the output — a "needs wiring" signal, matching og. A missing image slot draws a neutral stand-in (a gray box with the field name), never an embeddeddata:image/svg+xml(a source of black-square / lost-caption bugs in prior pipelines). OpenFresco.Substitute.slots(scene)lists a scene's{{slots}}with inferred:text/:imagetypes — for building a wiring UI.
Rendering to PNG
OpenFresco.render/3 rasterizes a scene to servable PNG bytes — browser-free
and deterministic:
{:ok, png, %{width: 1200, height: 630}} =
OpenFresco.render(scene, %{"title" => "Hello", "hero" => "data:…"})
Returns
{:error, term}(never raises) — including{:error, :rasterizer_missing}when no backend is reachable, so a caller can fall back to its own image.Requires a rasterizer. Add the optional
:resvgdep for the embedded NIF (recommended), or haveresvg/rsvg-convert/ ImageMagick on PATH:def deps, do: [{:open_fresco, "~> 0.1"}, {:resvg, "~> 0.5"}]No network at render time — image inputs must be
data:URLs or local files; remotehttp(s)hrefs are skipped by the embedded rasterizer.Caching + serving the PNG is the host's job. Fold
OpenFresco.Renderer.version/0into your cache key so cached PNGs invalidate when the generator or rasterizer changes.
OpenFresco.Rasterizer.which_backend/0 reports the reachable backend
(:resvg_nif / :resvg_cli / :rsvg / :magick / :none).
Serialization
json = Scene.to_json!(scene) # store it (e.g. in extensions["open_fresco"])
scene = Scene.from_json!(json) # runs Scene.migrate/1 forward
The format is versioned; unknown element fields are tolerated (forward- compat) and enum/type strings are never turned into new atoms.
Determinism & caching
render_svg/3 is a pure function of (scene, values). OpenFresco.version/0
returns the generator version string — fold it into cache keys so cached
output invalidates when the generator changes.
Editor (browser stage)
OpenFresco.Editor is a Phoenix.LiveComponent (optional :phoenix_live_view
dep) that renders a scene as a server-authoritative SVG preview and lets
the user select / drag / resize / reorder / delete elements. The server owns
layout, so what you edit is what renders.
<.live_component module={OpenFresco.Editor} id="og-editor"
scene={@scene} values={@values} />
It sends {:open_fresco_editor, id, {:scene_changed, scene}} and
{:selected, elem_id} to the parent (the host owns the properties panel).
Spread the hook into your LiveSocket: hooks: { ...window.OpenFrescoHooks }
(from priv/static/open_fresco.js). The pure editing core
(OpenFresco.Editor.Ops — hit-test / move / resize / reorder / delete) is
unit tested; the interactive component + hook are not yet browser-verified.
Migrating from phoenix_kit_og
OpenFresco.OgImport.scene_from_canvas/1 converts an og canvas map into a
scene. Because open_fresco adopted og's {{slot}} / [[global]] syntax, an
imported template resolves the same values and renders the same output — the
concrete step for an og host to move stored templates onto open_fresco.
scene = OpenFresco.OgImport.scene_from_canvas(template.canvas)
{:ok, png, _} = OpenFresco.render(scene, values, globals: globals)
License
MIT.