Skip to content

Updated the media browser with tessera#534

Merged
ddon merged 6 commits into
BeamLabEU:devfrom
alexdont:dev
May 11, 2026
Merged

Updated the media browser with tessera#534
ddon merged 6 commits into
BeamLabEU:devfrom
alexdont:dev

Conversation

@alexdont
Copy link
Copy Markdown
Contributor

No description provided.

Alexander Don and others added 6 commits May 11, 2026 22:22
The modal viewer for images now uses Tessera (OpenSeadragon under the
hood) instead of a plain `<img>`. The interaction model:

  - **Initial render**: the existing `medium` variant. Already in
    storage, ships fast, fills the viewport sharply. The user sees a
    clean preview the moment the modal opens — no blurry lowest-
    pyramid tile, no waiting for the original.

  - **On real zoom (past 2x fit-to-view)**: the viewer swaps to a DZI
    tile pyramid that's generated lazily, per-tile, on demand. OSD
    requests only the tiles its viewport covers at the current level;
    `FileController.serve_tile/2` checks storage, and on cache miss
    calls `Tessera.generate_tile/4` to crop one tile from the original
    and persist it via the Tessera storage adapter.

  - **Storage adapter**: `PhoenixKit.Modules.Storage.TesseraAdapter`
    implements `Tessera.Storage` by forwarding tile writes through
    `Storage.Manager.store_file/2` under a `_tiles/` prefix, so tiles
    land in every configured bucket alongside variants.

  - **URL exposure**: `generate_urls_from_instances/3` now takes the
    mime type and surfaces `urls["dzi"]` for image files. The manifest
    URL is always set; the manifest itself materializes lazily on the
    first request (it's just XML derived from the file's stored
    width/height, then cached like any other tile).

  - **Routes**: `/tiles/:dzi_filename` and
    `/tiles/:files_segment/:level/:tile_filename` map to the new
    `FileController.serve_manifest/2` + `serve_tile/2` actions; the
    `/tiles/:files_segment/...` shape matches OSD's tile-URL
    convention derived from a `<base>.dzi` manifest path.

`mix.exs` picks up `:tessera` as a `path:` dep until the package is
on hex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `system_managed` flag + `parent_file_uuid` FK to
`phoenix_kit_files` so every Tessera-generated DZI tile (and manifest)
gets a real DB row, not a shadow blob:

  * `system_managed BOOLEAN NOT NULL DEFAULT false` — flips MediaBrowser
    listings to hide these rows and short-circuits VariantGenerator
    (a 256×256 tile doesn't need its own thumbnail / medium / large)
  * `parent_file_uuid` references `phoenix_kit_files(uuid)` with
    `ON DELETE :delete_all` — deleting a source image cascades to its
    tiles automatically (DB-level)
  * partial indexes: per-parent for cleanup + listing, and a
    `system_managed = false` index keyed on `inserted_at DESC` so the
    user-facing "recent files" sort stays cheap as the tile catalog
    grows
  * drops `NOT NULL` on `user_uuid` (system rows don't have a user
    owner); the changeset's `validate_system_managed_invariants`
    enforces "user_uuid XOR parent_file_uuid" at the app level instead

`Storage.store_system_file/3` is the new entry point: writes through
`Manager.store_file/2` (so tiles replicate across every configured
bucket), then creates the File row + a single `"original"`
FileInstance — no variant fan-out. `TesseraAdapter.put/3` is rewired
to use it; `FileController.serve_tile/2` and `serve_manifest/2` pass
the source's UUID + content mime type via Tessera's `storage_opts`.

MediaBrowser listing/count queries (`list_files`, `list_files_in_scope`,
`orphaned_files_query`, `build_trashed_query`) get an
`exclude_system_managed` filter so tile chunks never appear as
user-facing media or as orphans.

Schema additions: `belongs_to :parent_file`, `"tile"` added to the
`file_type` allowlist. No data migration needed (default false +
nullable FK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous `src` + `upgrade_src` pair with a per-image
`sources` list. `tessera_sources/1` walks `f.urls` + the new
`f.variant_widths` map and produces an ordered low → high quality
layer list:

  * `medium` always — the initial render
  * `large`  only when its width actually exceeds medium's — small
    images skip this layer to avoid a useless middle hop
  * `dzi`    always for images — the top of the pyramid with
    infinite zoom headroom

Each non-DZI layer carries its intrinsic pixel width so Tessera's
client-side hook can compute thresholds dynamically (a layer is
"good enough" until zoom scales it past 2× its native resolution).

`generate_widths_from_instances/1` mirrors the URL builder, pulling
the existing `FileInstance.width` column (already populated by
`ImageProcessor.extract_dimensions/1` during variant generation —
no new field, no migration).

Three file-map call sites updated to thread `variant_widths`
alongside `urls`. Template's image branch swaps to `sources={tessera_sources(f)}`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `storage_tile_generation_enabled` (default `"false"`) to the
storage settings UI. With it off — the new default — the MediaBrowser
still does its multi-layer zoom (medium → large as the user zooms in),
but never surfaces a DZI manifest URL and never asks the lazy tile
endpoint for anything. ImageMagick effort is paid only when an
operator explicitly opts in via /admin/settings/media.

  * `generate_urls_from_instances/3` skips the `"dzi"` key when the
    setting is off — Tessera's `sources` list naturally degrades to the
    raster-variant layers.
  * `FileController.serve_manifest/2` and `serve_tile/2` short-circuit
    to 404 ("Tile generation disabled") so a stale client URL or a
    direct hit can't trigger generation when the toggle is off.
  * Settings LiveView gets the new checkbox + a `toggle_form_tile_generation`
    event; `apply_storage_settings` persists it alongside redundancy /
    auto-variants / max-upload-size and the apply-button's dirty check
    learns the extra field.

Why off by default: tile generation hits ImageMagick on every uncached
tile request, and most images in most apps don't need deep zoom. Users
who do want it flip one switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tessera 0.1.0 is live on Hex (https://hex.pm/packages/tessera). Swap
the in-tree path dep for the standard hex constraint so the package
resolves the same way everywhere — no sibling-checkout assumption,
parent apps consuming phoenix_kit from hex get tessera transparently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ddon ddon merged commit af98286 into BeamLabEU:dev May 11, 2026
ddon pushed a commit that referenced this pull request May 11, 2026
Findings: two CRITICAL (unauthenticated tile endpoints; synchronous
in-band tile generation = DoS surface), several HIGH (dedup race,
DB-level CHECK invariant, test coverage), plus MEDIUM/NITPICK items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
timujinne added a commit to timujinne/phoenix_kit that referenced this pull request May 11, 2026
ddon pushed a commit that referenced this pull request May 11, 2026
Addresses the CRITICAL + HIGH items from CLAUDE_REVIEW.md for the
Tessera deep-zoom feature.

Auth (CRITICAL):
- `/tiles/:token/:dzi_filename` and `/tiles/:token/:files_segment/...`
  now require a signed `URLSigner` token in the URL path. The "dzi"
  variant name is distinct from the storage variants so a leaked
  file-serving token can't grant tile access. Token lives in the path
  (not query) so OpenSeadragon's base-URL derivation preserves it
  across manifest → tile fetch transitions. Unauthorized requests
  return 404 (not 401/403) so UUID enumeration can't distinguish
  "wrong token" from "no such file".

Dedup (HIGH):
- V113 adds partial unique `phoenix_kit_files_system_dedup_index` on
  `(parent_file_uuid, file_name) WHERE system_managed = true`.
- `Storage.store_system_file/3` now does check-then-insert with
  unique-violation recovery: if a concurrent writer wins the race,
  we re-fetch their row instead of bubbling a Postgrex error.
- Per-`file_uuid` `:global.set_lock` mutex with double-checked
  locking in `FileController.ensure_manifest_cached/3` and
  `ensure_tile_cached/8` serializes cold-path generators for the
  same image while keeping different images parallel. Lock timeout
  surfaces as 503 + Retry-After header.

DB invariants (HIGH):
- V113 adds `phoenix_kit_files_user_or_parent_check` CHECK
  constraint enforcing `user_uuid IS NOT NULL OR parent_file_uuid
  IS NOT NULL` at the DB level. The schema's
  `validate_system_managed_invariants` is the user-facing check;
  this constraint is the safety net for raw inserts / Repo.insert_all.

Tempfile safety (HIGH):
- All `Manager.retrieve_file` + `Tessera.generate_tile` tempfile
  lifecycles wrapped in `try/after` so exceptions don't leak files
  into `System.tmp_dir!()`.

Test coverage (HIGH):
- New `test/phoenix_kit/migrations/v113_test.exs` pins each V113
  addition: column shape, nullability, FK cascade, three indexes,
  the new CHECK constraint (with a raw-SQL test that double-null
  inserts are rejected), and the `phoenix_kit_comment_media` table.

Misc:
- Dropped the misleading "previously V112 in dev branches" sentence
  from V113's moduledoc and the migrator changelog — public
  consumers will never hit that path.
- Fixed credo issues: aliased `Manager` in storage.ex's new helper;
  extracted nested case blocks into private functions to satisfy
  the max-depth check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request May 11, 2026
Republishing 1.7.108 within the 1-hour Hex replace window so the
Tessera deep-zoom feature (PR #534) and its review-fix follow-ups
ship under the same version, rather than as a separate 1.7.109.

mix.lock locks `tessera 0.1.0`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
timujinne added a commit to timujinne/phoenix_kit that referenced this pull request May 12, 2026
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 12, 2026
Upstream's V113 (PR BeamLabEU#534, Tessera tile media) added the
`phoenix_kit_files_user_or_parent_check` CHECK constraint requiring
`user_uuid IS NOT NULL OR parent_file_uuid IS NOT NULL`, but the
existing `create_file!/1` fixtures in three test files insert
`phoenix_kit_files` rows with neither column set — fixture pattern
predates V113. Result: 23 failures across:

  * `test/integration/storage/scope_test.exs` (17)
  * `test/integration/media_browser_scope_test.exs` (4)
  * `test/integration/phoenix_kit_web/components/media_browser_test.exs` (2)

All three reproduce on clean `upstream/dev` (commit `93f242b3`) with
zero changes from this PR — verified via a separate `git worktree`.

Fix: each `create_file!/1` now stamps `user_uuid` from a new
memoised `ensure_user!/0` helper. The helper registers an owner
user once per test process (via `PhoenixKit.Users.Auth.register_user/1`
+ `Process.put/2`) and reuses the uuid on subsequent calls — keeps
the existing call sites unchanged, avoids the unique-email collision
that would fire if we registered fresh per file. The user identity
itself is incidental to every assertion in these tests; it exists
only to satisfy the V113 CHECK.

Folded into this PR (rather than a follow-up) since the rebase
brought the broken upstream tests into our `mix test` baseline, and
leaving them red would mask future regressions in the next sweep.
Same fix-shape would apply on `upstream/dev` directly — if/when
alexdont or @timujinne ship a patch there, this commit should merge
cleanly with theirs (the bodies of `create_file!/1` are otherwise
unchanged).

Verification: full `mix test` 1229 tests / 1 failure (down from 24
on the unfixed baseline). The lone remaining failure is the
pre-existing `Permissions.module_label("db")` returning `"Db"`
instead of `"DB"` — flagged in an earlier session, unrelated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants