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:

ConstructorElementKey opts
Scene.text/2text block:value, :size, :weight, :font, :fill, :align, :line_height
Scene.image/2positioned image:value, :fit (:cover/:contain/:stretch), :radius
Scene.shape/2filled (rounded) rect:fill, :radius
Scene.button/2shape + 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

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:…"})

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.