Tessera

DZI deep-zoom + multi-layer progressive-quality layer for Fresco-based image viewers in Phoenix. Generate DZI (Deep Zoom Image) tile pyramids from images via ImageMagick — eagerly or lazily one tile at a time — and render them as a Fresco layer that swaps between source qualities as the user zooms.

A tessera is a single tile in a mosaic. Tessera the library produces and consumes those tiles, layered on top of a Fresco viewer.


Install

def deps do
  [
    {:fresco, "~> 0.1"},
    {:tessera, "~> 0.2"}
  ]
end

System requirement: ImageMagick (magick binary) on the host PATH for tile generation.

In your assets/js/app.js, import the JS hooks (Fresco first, then Tessera):

import "../../deps/fresco/priv/static/fresco.js"
import "../../deps/tessera/priv/static/tessera.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...window.FrescoHooks, ...window.TesseraHooks, ...colocatedHooks }
})

Render the viewer

Mount a Fresco viewer with a cheap preview, then attach a Tessera layer with the full source ladder. As the user zooms, Tessera swaps between layers automatically while preserving viewport bounds.

Two layers — medium + DZI deep zoom

<Fresco.viewer
  id="photo"
  src={~p"/uploads/photo-medium.jpg"}
  class="w-full h-[80vh] rounded"
/>

<Tessera.layer
  fresco_id="photo"
  sources={[
    %{url: ~p"/uploads/photo-medium.jpg", width: 1024},
    %{url: ~p"/dzi/photo.dzi"}
  ]}
/>

Three layers — medium + large + DZI (recommended for 4K+ images)

<Fresco.viewer
  id="poster"
  src={~p"/uploads/photo-medium.jpg"}
  class="w-full h-[80vh] rounded"
/>

<Tessera.layer
  fresco_id="poster"
  sources={[
    %{url: ~p"/uploads/photo-medium.jpg", width: 1024},
    %{url: ~p"/uploads/photo-large.jpg",  width: 2560},
    %{url: ~p"/dzi/photo.dzi"}
  ]}
/>

Each non-DZI source carries its intrinsic pixel width; Tessera computes the zoom threshold past which that source is upscaled and swaps to the next layer with hysteresis to prevent flicker. DZI entries omit width and act as the final layer (deep zoom covers all higher zoom levels).


Generate tiles

Eager — full pyramid in one shot

{:ok, %{manifest: manifest, tiles_dir: tiles_dir}} =
  Tessera.generate("/uploads/photo.jpg", "/var/www/dzi")

Output:

/var/www/dzi/photo.dzi              # XML manifest
/var/www/dzi/photo_files/0/0_0.jpg  # zoom level 0 (smallest tile)
...
/var/www/dzi/photo_files/N/c_r.jpg  # zoom level N, col c, row r

Options:

Tessera.generate(input, output_dir,
  tile_size: 256,    # pixels per tile edge
  overlap:   1,
  format:    :jpg,   # :jpg | :png
  base_name: "img"   # defaults to input basename without extension
)

Lazy — one tile at a time, on demand

For very large images, eagerly building the whole pyramid is wasteful — most tiles will never be looked at. Generate the manifest cheaply, then produce individual tiles on first request:

:ok = Tessera.generate_manifest({width, height}, "photo",
  storage: Tessera.Storage.Local,
  storage_opts: [root: "/var/cache/dzi"]
)

:ok = Tessera.generate_tile("/uploads/photo.jpg", {level, col, row}, "photo",
  image_width:  width,
  image_height: height,
  storage:      Tessera.Storage.Local,
  storage_opts: [root: "/var/cache/dzi"]
)

Pluggable storage

Tessera.Storage is a one-callback behaviour — Tessera writes generated tiles to a temp file, then hands them off via put/3:

defmodule MyApp.S3TileStorage do
  @behaviour Tessera.Storage

  def put(content_path, key, opts) do
    bucket = Keyword.fetch!(opts, :bucket)
    ExAws.S3.put_object(bucket, key, File.read!(content_path)) |> ExAws.request() |> case do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

Tessera.generate_tile(input, {1, 0, 0}, "photo",
  image_width: w, image_height: h,
  storage: MyApp.S3TileStorage,
  storage_opts: [bucket: "my-tiles"]
)

Reads / existence checks / deletes are the consumer's job — Tessera never reads back what it wrote.


Notes


What changed in 0.2

Tessera 0.1 was a standalone viewer (<Tessera.viewer src=...>). 0.2 is a Fresco layer (<Tessera.layer fresco_id=... sources=...>). The Fresco viewer owns OSD, the nav overlay, animations, and viewport clamping; Tessera focuses on what's actually distinctive (DZI source provider + multi-layer zoom logic).

The server-side Tessera.generate/3, Tessera.generate_manifest/3, Tessera.generate_tile/4, and Tessera.Storage API are unchanged. Migration from 0.1 to 0.2 only affects the template — see the usage examples above.


License

MIT — see LICENSE.