██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

Media Guide

This guide covers how to use @verevoir/assets and @verevoir/media together to manage, display, and resize images and video in a Verevoir application.

Overview

Two packages handle media:

@verevoir/media has no runtime dependency on @verevoir/assets. It defines an AssetSource interface; you provide an implementation. The package ships a prebuilt adapter that uses structural typing (duck typing).

Uploading Assets

import { AssetManager, MemoryBlobStore } from '@verevoir/assets';
import { MemoryAdapter } from '@verevoir/storage';

const storage = new MemoryAdapter();
const blobStore = new MemoryBlobStore();
await storage.connect();

const manager = new AssetManager({ storage, blobStore });

// Upload — dimensions extracted automatically for bitmap images
const asset = await manager.upload({
  data: imageBuffer,          // Uint8Array
  filename: 'hero.jpg',
  contentType: 'image/jpeg',
  createdBy: 'user-1',
});

console.log(asset.width);     // 1920
console.log(asset.height);    // 1080
console.log(asset.hotspot);   // null (not set yet)

Dimension Extraction

On upload, Sharp reads width and height from bitmap images. SVG and video assets get null dimensions — SVG is vector (no inherent pixel size), and video would require ffprobe (out of scope).

Hotspot

A hotspot marks the focal point of an image (e.g. a person's face). It's a property of the image itself, stored as normalised coordinates (0–1 range).

// Set a hotspot
await manager.updateMetadata(asset.id, {
  hotspot: { x: 0.5, y: 0.3 },  // centre-top
});

// Clear a hotspot
await manager.updateMetadata(asset.id, {
  hotspot: null,
});

// Rename the file
await manager.updateMetadata(asset.id, {
  filename: 'updated-hero.jpg',
});

updateMetadata() can change hotspot and filename. Blob-derived fields (size, contentType, width, height, type, format, createdBy) are immutable.

Bridging Assets to Media

The AssetSource interface decouples media display from asset storage:

import { createAssetSource } from '@verevoir/media';

const source = createAssetSource({
  manager,   // AssetManager instance (duck typed)
  blobUrl: (blobKey) => `https://cdn.example.com/blobs/${blobKey}`,
});

The blobUrl function maps blob keys to HTTP URLs. This is where you configure your storage backend — S3 presigned URLs, local dev server, CDN paths, etc.

Building Resize URLs

Verevoir generates deterministic imgproxy URLs. No resize logic runs in Verevoir itself — imgproxy handles resizing at serve time.

import { buildImageUrl, buildSrcSet, imageProps } from '@verevoir/media';

const config = { baseUrl: 'https://imgproxy.example.com' };

// Single URL
const asset = await source.getAsset(assetId);
const url = buildImageUrl(asset, { width: 400, height: 300 }, config);
// → https://imgproxy.example.com/resize:fill:400:300/plain/https://cdn.example.com/blobs/...

// With hotspot (gravity focal point)
const urlWithHotspot = buildImageUrl(
  { ...asset, hotspot: { x: 0.5, y: 0.3 } },
  { width: 400, height: 300 },
  config,
);
// → https://imgproxy.example.com/resize:fill:400:300/gravity:fp:0.5:0.3/plain/...

// srcSet for responsive images
const srcSet = buildSrcSet(asset, 400, 300, config);
// → ...resize:fill:400:300/plain/... 1x, ...resize:fill:800:600/plain/... 2x

Fit Modes

Rendering Helper

For output/rendering, use imageProps() to get ready-made <img> attributes:

const props = imageProps(asset, 400, 300, config);
// { src: '...', srcSet: '...', width: 400, height: 300 }
<img {...props} alt="Hero image" />

Image and Video Blocks

The media package provides pre-defined block definitions for embedding media in content:

import { imageBlock, videoBlock } from '@verevoir/media';

// Use in your content model
const data = {
  asset: '550e8400-e29b-41d4-a716-446655440000',
  alt: 'Team photo',
  displayWidth: 800,
  displayHeight: 600,
};
imageBlock.validate(data);  // throws on invalid

// Video has autoplay and loop (default false)
const videoData = {
  asset: '550e8400-e29b-41d4-a716-446655440000',
  displayWidth: 1280,
  displayHeight: 720,
  autoplay: true,
  loop: false,
};
videoBlock.validate(videoData);

React Components

Providers

Wrap your app with both providers:

import {
  AssetSourceProvider,
  ImgproxyConfigProvider,
} from '@verevoir/media';

function App() {
  return (
    <AssetSourceProvider source={source}>
      <ImgproxyConfigProvider config={{ baseUrl: 'https://imgproxy.example.com' }}>
        <Editor />
      </ImgproxyConfigProvider>
    </AssetSourceProvider>
  );
}

ImageField

Asset picker with thumbnail preview:

import { ImageField } from '@verevoir/media';

<ImageField
  value={selectedAssetId}
  onChange={(id) => setSelectedAssetId(id)}
  label="Hero Image"
/>

HotspotOverlay

Click-to-set focal point on an image:

import { HotspotOverlay } from '@verevoir/media';

<HotspotOverlay
  src={imageUrl}
  hotspot={currentHotspot}
  onChange={(hotspot) => updateHotspot(hotspot)}
/>

imgproxy Setup

Verevoir generates unsigned imgproxy URLs for v1. The ImgproxyConfig type reserves key and salt fields for future HMAC signing.

For local development, run imgproxy via Docker:

docker run -p 8080:8080 darthsim/imgproxy

Then configure:

const config = { baseUrl: 'http://localhost:8080' };