# 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.
