Packages
open_fresco
0.1.0
Server-side OG-image / social-card rendering, built on Fresco. A scene model (text, image, shape, button) with fills/gradients, {{slot}} templating, measured word-wrap, anchoring, and responsive sizing — rendered to SVG or PNG (via the optional resvg dependency).
Current section
Files
Jump to
Current section
Files
open_fresco
README.md
README.md
# OpenFresco
Server-side **OG-image / social-card** scenes, built to run on
[Fresco](https://hex.pm/packages/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
```elixir
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
```elixir
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:
```elixir
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).
```elixir
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:
```elixir
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:
```elixir
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`):
```elixir
"{{title}} — [[site_name]]" # a slot and a global, mixed in one string
Scene.placeholder("title") # whole-value shorthand for "{{title}}"
```
```elixir
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 `:globals` map (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 two `render_svg/3`
calls.
- 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 embedded `data: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` / `:image` types — for building a wiring UI.
## Rendering to PNG
`OpenFresco.render/3` rasterizes a scene to servable PNG bytes — browser-free
and deterministic:
```elixir
{: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 `:resvg` dep for the embedded
NIF (recommended), or have `resvg` / `rsvg-convert` / ImageMagick on PATH:
```elixir
def deps, do: [{:open_fresco, "~> 0.1"}, {:resvg, "~> 0.5"}]
```
- **No network at render time** — image inputs must be `data:` URLs or local
files; remote `http(s)` hrefs are skipped by the embedded rasterizer.
- Caching + serving the PNG is the host's job. Fold
`OpenFresco.Renderer.version/0` into 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
```elixir
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.
```elixir
<.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.
```elixir
scene = OpenFresco.OgImport.scene_from_canvas(template.canvas)
{:ok, png, _} = OpenFresco.render(scene, values, globals: globals)
```
## License
MIT.