Image Transforms
Pixelflare uses Cloudflare's Image Resizing API to serve images at different sizes and with various transformations. There are two ways to get transformed images:
- Preset variants - predefined sizes like
w1024,thumb,og-image - Custom transforms - on-demand transformations via URL query parameters
Both are cached in R2 after generation, so subsequent requests are fast.
Preset Variants
Defined in packages/config/src/image-variants.ts:
| Variant | Dimensions | Fit Mode | Description |
|---|---|---|---|
original | - | - | No transformation |
w128 | 128px width | scale-down | Tiny |
w256 | 256px width | scale-down | Small |
w512 | 512px width | scale-down | Medium |
w1024 | 1024px width | scale-down | Large |
w1536 | 1536px width | scale-down | X-Large |
w2048 | 2048px width | scale-down | XX-Large |
thumb | 128x128 | cover | Square thumbnail |
og-image | 1200x630 | cover | Social media card |
All preset variants use quality 85 and strip metadata by default.
Requesting Preset Variants
GET /{owner}/{album}/{filename}/{variant}
GET /i/{shortId}/{variant}Examples:
https://cdn.example.com/alice/photos/sunset.jpg/w1024
https://cdn.example.com/i/abc123/thumbCustom Transforms
Add query parameters to any image URL to apply transformations on-the-fly.
GET /{owner}/{album}/{filename}?w=800&h=600&fit=cover&q=90
GET /i/{shortId}?w=400&blur=10Available Parameters
All parameters are optional. Parameter validation and parsing happens in packages/config/src/custom-transform-params.ts.
| Param | Type | Range | Description |
|---|---|---|---|
w | number | 1-4096 | Width in pixels |
h | number | 1-4096 | Height in pixels |
fit | string | see below | Resize mode |
q | number | 1-100 | Quality (default 85) |
f | string | avif, webp, jpeg, baseline-jpeg, png, auto | Output format |
g | string | auto, center, face, top, bottom, left, right | Gravity/focus point |
blur | number | 1-250 | Blur radius |
sharpen | number | 0-10 | Sharpen amount |
br | number | 0.5-2.0 | Brightness |
co | number | 0.5-2.0 | Contrast |
sat | number | 0-2.0 | Saturation |
gam | number | 0.5-2.0 | Gamma |
r | number | 0, 90, 180, 270 | Rotation degrees |
flip | string | h, v, hv | Flip direction |
bg | string | 6-digit hex | Background color (no #) |
trim | string | top:N,left:N,... | Crop margins (max 1000px per side) |
meta | string | keep, copyright, none | Metadata handling |
segment | string | foreground | AI background removal (requires PNG/WebP) |
Fit modes:
scale-down- shrink to fit, never enlarge (default)contain- fit within dimensions, may letterboxcover- fill dimensions, may cropcrop- same as cover but allows gravity positioningpad- fit within dimensions, pad with background colorsqueeze- resize exactly to dimensions, ignoring aspect ratio
Background Removal (AI):
The segment parameter uses AI to automatically detect and isolate the subject:
foreground- removes background, making it transparent
⚠️ Important: Background removal creates transparent pixels, so:
- Use PNG or WebP format for best results (
?segment=foreground&f=png) - JPEG does not support transparency and may show artifacts
- Works best with images that have clear foreground subjects
Example:
https://cdn.example.com/alice/photos/portrait.jpg?segment=foreground&f=pngRate Limits
Custom transforms have two safeguards:
- Rate limit: Max 10 new variants per 60 seconds per IP
- Variant limit: Max 100 custom variants per image
Once a custom variant is cached, it can be accessed unlimited times. The rate limit only applies to generating new variants.
Format Conversion via Extension
Images can be converted to different formats by simply changing or adding the file extension in the URL. This provides a clean, intuitive API for format conversion.
How It Works
The format extraction logic is in packages/shared/src/urls.ts:
export function extractRequestedFormat(requestedFilename: string): {
baseFilename: string;
requestedFormat: ImageFormat | null;
};This function:
- Extracts the last extension from the filename
- Checks if it's a valid image format (
.webp,.avif,.png,.jpg,.jpeg) - Returns the base filename and requested format
Examples:
extractRequestedFormat('sunset.png.webp');
// → { baseFilename: 'sunset.png', requestedFormat: 'webp' }
extractRequestedFormat('photo.avif');
// → { baseFilename: 'photo', requestedFormat: 'avif' }
extractRequestedFormat('image.jpg');
// → { baseFilename: 'image.jpg', requestedFormat: null }Request Flow
1. URL received: /i/abc123.webp or /alice/photos/sunset.webp
2. Extract format: extractRequestedFormat() → 'webp'
3. Database lookup: Find image with base filename
4. Check encrypted: Reject if image.is_encrypted === 1
5. Check query params: If ?f=avif, use 'avif' (param wins)
6. Generate variant key: Include format in R2 key
Example: alice/photos/sunset/__variants/w1024.webp
7. Transform via Cloudflare: Pass format to cf.image.format
8. Cache & serve: Store with format extensionFormat Precedence
The final format is determined in this order:
- Query parameter (
?f=webp) - highest priority - URL extension (
.webp) - medium priority - Auto - lowest priority (Cloudflare negotiates)
Implementation in core-serving.ts:
// Query param takes precedence
const finalFormat = customParams.f || requestedFormatFromUrl;Storage Strategy
Format variants are stored separately in R2:
alice/photos/
├── sunset.jpg # Original
├── sunset/__variants/
│ ├── w1024.jpg # Default format (same as original)
│ ├── w1024.webp # WebP format variant
│ ├── w1024.avif # AVIF format variant
│ ├── thumb.jpg # Thumbnail (default)
│ └── thumb.webp # Thumbnail (WebP)This allows the same variant (e.g., w1024) in multiple formats.
Limitations
Format conversion is blocked for encrypted images for security and complexity reasons:
if (image.is_encrypted === 1 && requestedFormat) {
return c.json(
{
code: 'BAD_REQUEST',
message: 'Format conversion is not supported for encrypted images',
},
400
);
}Examples
# Short URL with WebP conversion
GET /i/abc123.webp
# Full path with AVIF conversion
GET /alice/photos/sunset.avif
# Variant + format conversion
GET /alice/photos/sunset.webp/w1024
# Short URL + variant + format
GET /i/abc123.avif/thumb
# Custom transforms + format (query param wins)
GET /i/abc123.webp?w=800&f=avif # Results in AVIFAnalytics
Format requests are tracked in the analytics system via the requestedFormat field:
trackImageServe(c.executionCtx, c.env, {
// ...
requestedFormat: finalFormat || null,
});This allows analyzing format conversion usage patterns, though the format is currently passed but not stored in the database (would require schema migration).
How It Works
Request Flow
graph TD
A[Request] --> B{Has query params?}
B -->|No| C{Variant in path?}
B -->|Yes| D[Parse custom params]
C -->|No| E[Serve original from R2]
C -->|Yes| F[Check R2 for variant]
D --> G{Valid params?}
G -->|No| H[400 Bad Request]
G -->|Yes| I[Check R2 cache]
I -->|Hit| J[Serve from R2]
I -->|Miss| K{Rate limit OK?}
K -->|No| L[429 Too Many Requests]
K -->|Yes| M{Under 100 variants?}
M -->|No| N[400 Variant Limit]
M -->|Yes| O[Transform via CF API]
O --> P[Cache to R2]
P --> J
F -->|Hit| Q[Serve from R2]
F -->|Miss| R[Transform on-demand or queue]
R --> QStorage Layout
Images and variants are stored in R2 with this structure:
{owner}/{album}/
├── {filename} # Original
├── {filename}/__variants/
│ ├── w128.jpg # Preset variants
│ ├── w256.jpg
│ ├── w512.jpg
│ ├── w1024.jpg
│ ├── thumb.jpg
│ └── og-image.jpg
├── {filename}-custom-w400-h300-fitcover # Custom variants
└── {filename}-custom-w800-q90-blur5The variant key for custom transforms is built from all parameters in a consistent order, e.g., w400-h300-q85-fitcover-gface. This is handled by buildCustomVariantKey() in the config package.
Cloudflare Image Resizing
Transformations use Cloudflare's Image Resizing API via the cf.image fetch option. The relevant code is in packages/api/src/lib/variants.ts.
For this to work, images must be accessible via URL (not raw bytes). The API needs either:
- An R2 custom domain configured (
R2_CUSTOM_DOMAINenv var), or - The original image accessible via the CDN
When a transform succeeds, Cloudflare adds a cf-resized header to the response.
Frontend Components
The custom variant builder UI is in packages/frontend/src/lib/components/image-details/CustomVariantBuilder.svelte. It renders form fields based on the field definitions in packages/config/src/custom-transform-fields.ts.
Features:
- Auto-generated form fields from config
- Input validation with min/max clamping
- Color picker for background
- Test button to preview in popup
- Save button to save parameter combinations
Saved Custom Variants
Users can save their favourite parameter combinations for reuse. Saved variants are stored in the user_settings table and appear in the variant dropdown with a star prefix.
Limits:
- Max 20 saved variants per user
- Names must be 1-50 characters
- Names can only contain letters, numbers, spaces, hyphens, underscores
The management UI is at /app/settings/embed-settings, implemented in SavedVariantsSettings.svelte.
API:
# Save a variant
PATCH /v1/settings
{ "saved_custom_variant": { "name": "Blog Thumb", "params": { "w": 400, "h": 300, "fit": "cover" } } }
# Delete a variant
PATCH /v1/settings
{ "delete_variant_id": "variant_abc123" }
# Rename a variant
PATCH /v1/settings
{ "update_variant": { "id": "variant_abc123", "name": "New Name" } }Adding a New Transform Parameter
To add a new parameter, you need to update three places:
1. Validation logic
In packages/config/src/custom-transform-params.ts:
// Add to interface
export interface CustomTransformParams {
// ...
yourparam?: number;
}
// Add to param keys array
export const CUSTOM_PARAM_KEYS = [
// ...
'yourparam',
] as const;
// Add validation in parseCustomTransformParams()
const yourparam = searchParams.yourparam;
if (yourparam) {
const value = parseInt(yourparam, 10);
if (isNaN(value) || value < 1 || value > 100) return null;
params.yourparam = value;
hasParams = true;
}
// Add to buildCustomVariantKey()
if (params.yourparam) parts.push(`yourparam${params.yourparam}`);
// Add to customParamsToImageTransformOptions()
if (params.yourparam !== undefined) options.yourCloudflareOption = params.yourparam;2. UI field definition
In packages/config/src/custom-transform-fields.ts:
export const CUSTOM_TRANSFORM_FIELDS: CustomTransformField[] = [
// ...
{
key: 'yourparam',
label: 'Your Param',
type: 'number', // or 'select' or 'color'
placeholder: '50',
min: 1,
max: 100,
step: 1,
grid: 'half', // or 'full'
},
];3. OpenAPI schema (optional)
If you want the parameter documented in the API spec, update packages/api/src/schemas/cdn.ts.
Error Responses
| Code | Error | Cause |
|---|---|---|
| 400 | INVALID_PARAMS | Invalid parameter value |
| 400 | VARIANT_LIMIT_EXCEEDED | Image has 100+ custom variants |
| 429 | RATE_LIMITED | Too many new variants (>10/min) |
| 404 | NOT_FOUND | Image doesn't exist |
| 403 | FORBIDDEN | Private image, not authenticated |
Response Headers
Transformed images include these headers:
Cache-Control: public, max-age=31536000, immutable- cache for 1 yearX-Request-ID- request ID for tracingX-Variant-Status- one of:original- served original, no transformtransformed- generated on-demandcached- served from R2 cachegenerating- queued for async generation