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 rOptions:
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
- Tile URLs: OSD derives tile URLs from a DZI manifest's location by appending
_files/<level>/<col>_<row>.<format>. Make sure your tile-serving routes match. - Viewport preservation on swap: handled by Fresco's
swapSourcePreservingBounds— Tessera asks Fresco to swap; Fresco does the bounds-preserving open. - Built-in viewer chrome (nav buttons, pan clamping, animations) comes from Fresco; Tessera only contributes the source-provider + multi-layer ladder.
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.