# Tessera
DZI deep-zoom + multi-layer progressive-quality layer for [Fresco](https://hex.pm/packages/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
```elixir
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):
```js
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
```heex
<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)
```heex
<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
```elixir
{: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:
```elixir
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:
```elixir
: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`:
```elixir
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](./LICENSE).