Initial commit
This commit is contained in:
403
references/api-reference.md
Normal file
403
references/api-reference.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Cloudflare Images API Reference
|
||||
|
||||
Complete API endpoints for Cloudflare Images.
|
||||
|
||||
**Base URL**: `https://api.cloudflare.com/client/v4/accounts/{account_id}`
|
||||
**Batch API**: `https://batch.imagedelivery.net`
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests require an API token with **Cloudflare Images: Edit** permission.
|
||||
|
||||
```bash
|
||||
Authorization: Bearer <API_TOKEN>
|
||||
```
|
||||
|
||||
Get API token: Dashboard → My Profile → API Tokens → Create Token
|
||||
|
||||
---
|
||||
|
||||
## Upload Endpoints
|
||||
|
||||
### Upload Image (File)
|
||||
|
||||
`POST /accounts/{account_id}/images/v1`
|
||||
|
||||
Upload an image file.
|
||||
|
||||
**Headers**:
|
||||
- `Authorization: Bearer <API_TOKEN>`
|
||||
- `Content-Type: multipart/form-data`
|
||||
|
||||
**Form Fields**:
|
||||
- `file` (required): Image file
|
||||
- `id` (optional): Custom ID (auto-generated if not provided)
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object (max 1024 bytes)
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request POST \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--form 'file=@./image.jpg' \
|
||||
--form 'requireSignedURLs=false' \
|
||||
--form 'metadata={"key":"value"}'
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"variants": [
|
||||
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upload via URL
|
||||
|
||||
`POST /accounts/{account_id}/images/v1`
|
||||
|
||||
Ingest image from external URL.
|
||||
|
||||
**Form Fields**:
|
||||
- `url` (required): Image URL to ingest
|
||||
- `id` (optional): Custom ID
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request POST \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--form 'url=https://example.com/image.jpg' \
|
||||
--form 'metadata={"source":"external"}'
|
||||
```
|
||||
|
||||
**Note**: Cannot use both `file` and `url` in same request.
|
||||
|
||||
---
|
||||
|
||||
### Direct Creator Upload
|
||||
|
||||
`POST /accounts/{account_id}/images/v2/direct_upload`
|
||||
|
||||
Generate one-time upload URL for user uploads.
|
||||
|
||||
**Headers**:
|
||||
- `Authorization: Bearer <API_TOKEN>`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"requireSignedURLs": false,
|
||||
"metadata": {"userId": "12345"},
|
||||
"expiry": "2025-10-26T18:00:00Z",
|
||||
"id": "custom-id"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object
|
||||
- `expiry` (optional): ISO 8601 timestamp (default: 30min, max: 6hr)
|
||||
- `id` (optional): Custom ID (cannot use with `requireSignedURLs=true`)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"uploadURL": "https://upload.imagedelivery.net/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Upload**:
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]); // MUST be named 'file'
|
||||
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // NO Content-Type header
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Management
|
||||
|
||||
### List Images
|
||||
|
||||
`GET /accounts/{account_id}/images/v2`
|
||||
|
||||
List all images (paginated).
|
||||
|
||||
**Query Params**:
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `per_page` (optional): Results per page (default: 100, max: 100)
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2?page=1&per_page=50" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"images": [
|
||||
{
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"variants": ["https://imagedelivery.net/.../public"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Image Details
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/{image_id}`
|
||||
|
||||
Get details of specific image.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"draft": false,
|
||||
"variants": ["https://imagedelivery.net/.../public"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: `draft: true` means Direct Creator Upload not completed yet.
|
||||
|
||||
---
|
||||
|
||||
### Delete Image
|
||||
|
||||
`DELETE /accounts/{account_id}/images/v1/{image_id}`
|
||||
|
||||
Delete an image.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variants Management
|
||||
|
||||
### Create Variant
|
||||
|
||||
`POST /accounts/{account_id}/images/v1/variants`
|
||||
|
||||
Create a new variant.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"id": "thumbnail",
|
||||
"options": {
|
||||
"fit": "cover",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"metadata": "none"
|
||||
},
|
||||
"neverRequireSignedURLs": false
|
||||
}
|
||||
```
|
||||
|
||||
**Options**:
|
||||
- `fit`: `scale-down`, `contain`, `cover`, `crop`, `pad`
|
||||
- `width`: Max width in pixels
|
||||
- `height`: Max height in pixels
|
||||
- `metadata`: `none`, `copyright`, `keep`
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"id":"thumbnail","options":{"fit":"cover","width":300,"height":300}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Variants
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/variants`
|
||||
|
||||
List all variants.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Variant
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Get specific variant details.
|
||||
|
||||
---
|
||||
|
||||
### Update Variant
|
||||
|
||||
`PATCH /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Update existing variant.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"options": {
|
||||
"width": 350,
|
||||
"height": 350
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Delete Variant
|
||||
|
||||
`DELETE /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Delete a variant.
|
||||
|
||||
---
|
||||
|
||||
### Enable Flexible Variants
|
||||
|
||||
`PATCH /accounts/{account_id}/images/v1/config`
|
||||
|
||||
Enable or disable flexible variants (dynamic transformations).
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"flexible_variants": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch API
|
||||
|
||||
Same endpoints as regular API, but different host and authentication.
|
||||
|
||||
**Host**: `https://batch.imagedelivery.net`
|
||||
**Auth**: Batch token (create in Dashboard → Images → Batch API)
|
||||
|
||||
**Endpoints**:
|
||||
- `POST /images/v1` - Upload image
|
||||
- `GET /images/v2` - List images
|
||||
- `DELETE /images/v1/{image_id}` - Delete image
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://batch.imagedelivery.net/images/v1" \
|
||||
--header "Authorization: Bearer <BATCH_TOKEN>" \
|
||||
--form 'file=@./image.jpg'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
### HTTP Status Codes
|
||||
- `200 OK` - Request successful
|
||||
- `400 Bad Request` - Invalid request (check error message)
|
||||
- `401 Unauthorized` - Invalid or missing API token
|
||||
- `403 Forbidden` - Insufficient permissions
|
||||
- `404 Not Found` - Resource not found
|
||||
- `413 Payload Too Large` - File too large
|
||||
- `429 Too Many Requests` - Rate limit exceeded
|
||||
- `500 Internal Server Error` - Cloudflare error
|
||||
- `502 Bad Gateway` - Transformation error
|
||||
|
||||
### Cloudflare Errors
|
||||
Check `errors` array in response:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": 5400,
|
||||
"message": "Error description"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
- `5400` - Invalid request
|
||||
- `5408` - Upload timeout
|
||||
- `5454` - Unsupported protocol
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- **Standard uploads**: No published rate limits
|
||||
- **Direct Creator Upload**: Limited by one-time URL expiry (default 30min, max 6hr)
|
||||
- **Batch API**: Contact Cloudflare for high-volume needs
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Images API**: https://developers.cloudflare.com/api/resources/images/
|
||||
- **Upload Images**: https://developers.cloudflare.com/images/upload-images/
|
||||
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
|
||||
- **Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
|
||||
393
references/direct-upload-complete-workflow.md
Normal file
393
references/direct-upload-complete-workflow.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Direct Creator Upload - Complete Workflow
|
||||
|
||||
Complete architecture and implementation guide for user-uploaded images.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Browser │ │ Backend │ │Cloudflare│
|
||||
│ (User) │ │ API │ │ Images │
|
||||
└────┬────┘ └────┬────┘ └─────┬────┘
|
||||
│ │ │
|
||||
│ 1. Request upload URL │ │
|
||||
├─────────────────────────────>│ │
|
||||
│ POST /api/upload-url │ │
|
||||
│ { userId: "123" } │ │
|
||||
│ │ │
|
||||
│ │ 2. Generate upload URL │
|
||||
│ ├──────────────────────────────>│
|
||||
│ │ POST /direct_upload │
|
||||
│ │ { requireSignedURLs, metadata }│
|
||||
│ │ │
|
||||
│ │ 3. Return uploadURL + ID │
|
||||
│ │<──────────────────────────────┤
|
||||
│ │ { uploadURL, id } │
|
||||
│ │ │
|
||||
│ 4. Return uploadURL │ │
|
||||
│<─────────────────────────────┤ │
|
||||
│ { uploadURL, imageId } │ │
|
||||
│ │ │
|
||||
│ 5. Upload file directly │ │
|
||||
├──────────────────────────────────────────────────────────────>│
|
||||
│ POST uploadURL │ │
|
||||
│ FormData: { file } │ │
|
||||
│ │ │
|
||||
│ 6. Success response │ │
|
||||
│<──────────────────────────────────────────────────────────────┤
|
||||
│ { success: true } │ │
|
||||
│ │ │
|
||||
│ 7. (Optional) Webhook │ │
|
||||
│ │<──────────────────────────────┤
|
||||
│ │ POST /webhook │
|
||||
│ │ { imageId, status } │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backend - Generate Upload URL
|
||||
|
||||
**Endpoint**: `POST /api/upload-url`
|
||||
|
||||
```typescript
|
||||
// backend.ts
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// Parse request
|
||||
const body = await request.json<{
|
||||
userId?: string;
|
||||
requireSignedURLs?: boolean;
|
||||
}>();
|
||||
|
||||
// Generate one-time upload URL
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requireSignedURLs: body.requireSignedURLs ?? false,
|
||||
metadata: {
|
||||
userId: body.userId || 'anonymous',
|
||||
uploadedAt: new Date().toISOString()
|
||||
},
|
||||
expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return Response.json({
|
||||
uploadURL: result.result?.uploadURL,
|
||||
imageId: result.result?.id
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Frontend - Request Upload URL
|
||||
|
||||
```javascript
|
||||
// frontend.js
|
||||
async function requestUploadURL() {
|
||||
const response = await fetch('/api/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: getCurrentUserId(),
|
||||
requireSignedURLs: false
|
||||
})
|
||||
});
|
||||
|
||||
const { uploadURL, imageId } = await response.json();
|
||||
return { uploadURL, imageId };
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Frontend - Upload to Cloudflare
|
||||
|
||||
```javascript
|
||||
async function uploadImage(file) {
|
||||
// Step 1: Get upload URL
|
||||
const { uploadURL, imageId } = await requestUploadURL();
|
||||
|
||||
// Step 2: Upload directly to Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('file', file); // MUST be named 'file'
|
||||
|
||||
const response = await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // NO Content-Type header - browser sets multipart/form-data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return imageId;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend HTML Example
|
||||
|
||||
```html
|
||||
<form id="upload-form">
|
||||
<input type="file" id="file-input" accept="image/*" />
|
||||
<button type="submit">Upload</button>
|
||||
<div id="status"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.getElementById('upload-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const status = document.getElementById('status');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
status.textContent = 'Please select a file';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
status.textContent = 'Requesting upload URL...';
|
||||
|
||||
// Get upload URL from backend
|
||||
const urlResponse = await fetch('/api/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: 'user-123' })
|
||||
});
|
||||
|
||||
const { uploadURL, imageId } = await urlResponse.json();
|
||||
|
||||
status.textContent = 'Uploading...';
|
||||
|
||||
// Upload directly to Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadResponse = await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
status.textContent = `✓ Upload successful! Image ID: ${imageId}`;
|
||||
} else {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
status.textContent = `✗ Error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
### Configure Webhook
|
||||
|
||||
1. Dashboard → Notifications → Destinations → Webhooks → Create
|
||||
2. Enter webhook URL: `https://your-backend.com/webhook`
|
||||
3. Notifications → All Notifications → Add → Images → Select webhook
|
||||
|
||||
### Handle Webhook
|
||||
|
||||
```typescript
|
||||
// backend-webhook.ts
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const webhook = await request.json();
|
||||
|
||||
console.log('Image upload webhook:', webhook);
|
||||
// {
|
||||
// imageId: "abc123",
|
||||
// status: "uploaded",
|
||||
// metadata: { userId: "user-123" }
|
||||
// }
|
||||
|
||||
// Update database
|
||||
await db.images.create({
|
||||
id: webhook.imageId,
|
||||
userId: webhook.metadata.userId,
|
||||
status: webhook.status,
|
||||
uploadedAt: new Date()
|
||||
});
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Draft vs Uploaded State
|
||||
|
||||
When you generate upload URL, image record is created in **draft** state.
|
||||
|
||||
**Check status**:
|
||||
```typescript
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${apiToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.result?.draft) {
|
||||
console.log('Upload not completed yet');
|
||||
} else {
|
||||
console.log('Upload complete, image available');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Errors
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await fetch(directUploadURL, { ... });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(`Cloudflare error: ${error.errors?.[0]?.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate upload URL:', error);
|
||||
return Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Errors
|
||||
|
||||
```javascript
|
||||
// File size validation
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new Error('File too large (max 10MB)');
|
||||
}
|
||||
|
||||
// File type validation
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Please select an image file');
|
||||
}
|
||||
|
||||
// Upload timeout
|
||||
const timeout = setTimeout(() => {
|
||||
throw new Error('Upload timeout (30s limit)');
|
||||
}, 28000); // 28s (before Cloudflare's 30s timeout)
|
||||
|
||||
try {
|
||||
await fetch(uploadURL, { body: formData });
|
||||
clearTimeout(timeout);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom ID Support
|
||||
|
||||
```typescript
|
||||
// Generate upload URL with custom ID
|
||||
const response = await fetch(directUploadURL, {
|
||||
method: 'POST',
|
||||
headers: { ... },
|
||||
body: JSON.stringify({
|
||||
id: `user-${userId}-profile`, // Custom ID
|
||||
metadata: { userId }
|
||||
})
|
||||
});
|
||||
|
||||
// Access with custom ID
|
||||
const imageURL = `https://imagedelivery.net/${accountHash}/user-${userId}-profile/public`;
|
||||
```
|
||||
|
||||
**Note**: Custom IDs cannot be used with `requireSignedURLs=true`.
|
||||
|
||||
---
|
||||
|
||||
## Expiry Configuration
|
||||
|
||||
```typescript
|
||||
// Default: 30 minutes
|
||||
// Min: 2 minutes
|
||||
// Max: 6 hours
|
||||
|
||||
const expiry = new Date(Date.now() + 6 * 60 * 60 * 1000); // 6 hours
|
||||
|
||||
const response = await fetch(directUploadURL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expiry: expiry.toISOString()
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never expose API token to browser**: Backend-only
|
||||
2. **Validate file type and size**: Frontend and backend
|
||||
3. **Rate limit upload URL generation**: Prevent abuse
|
||||
4. **Associate uploads with users**: Track in metadata
|
||||
5. **Implement webhooks**: Verify successful uploads
|
||||
6. **Set reasonable expiry**: 30min-1hr for most cases
|
||||
7. **Use signed URLs for private content**: `requireSignedURLs=true`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test backend endpoint
|
||||
curl -X POST http://localhost:8787/api/upload-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId":"test-user"}'
|
||||
|
||||
# Test upload (replace UPLOAD_URL with response)
|
||||
curl -X POST "UPLOAD_URL" \
|
||||
-F "file=@./test-image.jpg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
|
||||
- **Configure Webhooks**: https://developers.cloudflare.com/images/manage-images/configure-webhooks/
|
||||
359
references/format-optimization.md
Normal file
359
references/format-optimization.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Format Optimization
|
||||
|
||||
Complete guide to automatic WebP/AVIF conversion and format selection.
|
||||
|
||||
---
|
||||
|
||||
## format=auto (Recommended)
|
||||
|
||||
Automatically serve optimal format based on browser support.
|
||||
|
||||
**Priority**:
|
||||
1. **AVIF** - Best compression (Chrome, Edge)
|
||||
2. **WebP** - Good compression (Safari, Firefox)
|
||||
3. **Original format** - Fallback (older browsers)
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
// URL format
|
||||
/cdn-cgi/image/width=800,quality=85,format=auto/image.jpg
|
||||
|
||||
// Workers format
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cloudflare Images
|
||||
https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser Support Detection
|
||||
|
||||
Cloudflare automatically checks the `Accept` header.
|
||||
|
||||
**Chrome/Edge**:
|
||||
```
|
||||
Accept: image/avif,image/webp,image/apng,image/*,*/*
|
||||
```
|
||||
→ Serves AVIF
|
||||
|
||||
**Safari**:
|
||||
```
|
||||
Accept: image/webp,image/apng,image/*,*/*
|
||||
```
|
||||
→ Serves WebP
|
||||
|
||||
**Older browsers**:
|
||||
```
|
||||
Accept: image/jpeg,image/png,image/*,*/*
|
||||
```
|
||||
→ Serves original format (JPEG)
|
||||
|
||||
---
|
||||
|
||||
## Manual Format Selection
|
||||
|
||||
### In URL Transformations
|
||||
|
||||
```html
|
||||
<!-- AVIF (best compression) -->
|
||||
<img src="/cdn-cgi/image/format=avif/image.jpg" />
|
||||
|
||||
<!-- WebP (good compression, wide support) -->
|
||||
<img src="/cdn-cgi/image/format=webp/image.jpg" />
|
||||
|
||||
<!-- JPEG (progressive) -->
|
||||
<img src="/cdn-cgi/image/format=jpeg/image.jpg" />
|
||||
|
||||
<!-- Baseline JPEG (older devices) -->
|
||||
<img src="/cdn-cgi/image/format=baseline-jpeg/image.jpg" />
|
||||
```
|
||||
|
||||
### In Workers
|
||||
|
||||
```typescript
|
||||
// Get optimal format from Accept header
|
||||
function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' {
|
||||
const accept = request.headers.get('accept') || '';
|
||||
|
||||
if (/image\/avif/.test(accept)) {
|
||||
return 'avif';
|
||||
} else if (/image\/webp/.test(accept)) {
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
return 'auto'; // Cloudflare decides
|
||||
}
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: getOptimalFormat(request)
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Comparison
|
||||
|
||||
| Format | Compression | Quality | Support | Use Case |
|
||||
|--------|-------------|---------|---------|----------|
|
||||
| **AVIF** | Best (~50% smaller) | Excellent | Modern browsers | First choice (auto) |
|
||||
| **WebP** | Good (~30% smaller) | Excellent | Wide support | Fallback from AVIF |
|
||||
| **JPEG** | Standard | Good | Universal | Fallback, photos |
|
||||
| **PNG** | Lossless | Lossless | Universal | Graphics, transparency |
|
||||
|
||||
**File Size Example** (1920x1080 photo):
|
||||
- Original JPEG: 500 KB
|
||||
- WebP: ~350 KB (30% smaller)
|
||||
- AVIF: ~250 KB (50% smaller)
|
||||
|
||||
---
|
||||
|
||||
## Progressive vs Baseline JPEG
|
||||
|
||||
**Progressive JPEG** (default):
|
||||
- Loads in multiple passes (low→high quality)
|
||||
- Better for slow connections
|
||||
- Slightly larger file size
|
||||
|
||||
**Baseline JPEG**:
|
||||
- Loads top-to-bottom
|
||||
- Better for older devices
|
||||
- Slightly smaller file size
|
||||
|
||||
**Usage**:
|
||||
```
|
||||
format=jpeg → Progressive JPEG
|
||||
format=baseline-jpeg → Baseline JPEG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebP Compression Modes
|
||||
|
||||
```typescript
|
||||
// Fast compression (faster encoding, larger file)
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
compression: 'fast'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Lossless WebP (no quality loss, larger file)
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
compression: 'lossless'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Images with format=auto
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<!-- Explicit AVIF for modern browsers -->
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=avif 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=avif 1920w
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- WebP fallback -->
|
||||
<source
|
||||
type="image/webp"
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=webp 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=webp 1920w
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- JPEG fallback -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=jpeg 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=jpeg 1920w
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=jpeg"
|
||||
alt="Responsive image with format fallbacks"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<!-- OR: Let format=auto handle it -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Auto-format responsive image"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality Recommendations by Format
|
||||
|
||||
```typescript
|
||||
const qualitySettings = {
|
||||
jpeg: 85, // Standard for photos
|
||||
webp: 85, // Same as JPEG
|
||||
avif: 85, // AVIF efficient at same quality
|
||||
png: undefined, // Lossless (quality N/A)
|
||||
graphics: 95 // High quality for logos/text
|
||||
};
|
||||
|
||||
// Photos
|
||||
/cdn-cgi/image/width=800,quality=85,format=auto/photo.jpg
|
||||
|
||||
// Graphics with text
|
||||
/cdn-cgi/image/width=800,quality=95,format=auto/logo.png
|
||||
|
||||
// Thumbnails (lower quality acceptable)
|
||||
/cdn-cgi/image/width=300,quality=75,format=auto/thumb.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Support
|
||||
|
||||
**GIF**:
|
||||
```
|
||||
format=auto → Still GIF or first frame
|
||||
anim=true → Preserve animation
|
||||
```
|
||||
|
||||
**Animated WebP**:
|
||||
```typescript
|
||||
fetch(animatedGif, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
anim: true // Preserve animation
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metadata Handling
|
||||
|
||||
**Strip metadata** (smaller file size):
|
||||
```
|
||||
metadata=none
|
||||
```
|
||||
|
||||
**Keep copyright** (default for JPEG):
|
||||
```
|
||||
metadata=copyright
|
||||
```
|
||||
|
||||
**Keep all EXIF** (GPS, camera settings):
|
||||
```
|
||||
metadata=keep
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
/cdn-cgi/image/width=800,format=auto,metadata=none/photo.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
1. **Use format=auto**: Smallest files = less bandwidth
|
||||
2. **Reasonable quality**: 80-85 for photos, 90-95 for graphics
|
||||
3. **Strip metadata**: `metadata=none` for public images
|
||||
4. **Cache at edge**: First transformation billable, subsequent free
|
||||
5. **WebP animations**: Convert GIF to animated WebP (smaller)
|
||||
|
||||
---
|
||||
|
||||
## Testing Format Support
|
||||
|
||||
```html
|
||||
<script>
|
||||
// Check AVIF support
|
||||
const avifSupport = document.createElement('canvas')
|
||||
.toDataURL('image/avif').indexOf('data:image/avif') === 0;
|
||||
|
||||
// Check WebP support
|
||||
const webpSupport = document.createElement('canvas')
|
||||
.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
||||
|
||||
console.log('AVIF:', avifSupport); // true in Chrome/Edge
|
||||
console.log('WebP:', webpSupport); // true in modern browsers
|
||||
</script>
|
||||
```
|
||||
|
||||
**But**: Let Cloudflare handle this with `format=auto`!
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Hero Image
|
||||
```
|
||||
width=1920,height=1080,fit=cover,quality=85,format=auto,metadata=none
|
||||
```
|
||||
|
||||
### Thumbnail
|
||||
```
|
||||
width=300,height=300,fit=cover,quality=75,format=auto,metadata=none
|
||||
```
|
||||
|
||||
### Avatar
|
||||
```
|
||||
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Product Photo
|
||||
```
|
||||
width=800,height=800,fit=contain,quality=90,sharpen=2,format=auto
|
||||
```
|
||||
|
||||
### Blur Placeholder (LQIP)
|
||||
```
|
||||
width=50,quality=10,blur=20,format=webp,metadata=none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use format=auto**: Let Cloudflare optimize
|
||||
2. **Quality 80-90**: Balance file size and quality
|
||||
3. **Strip unnecessary metadata**: Smaller files
|
||||
4. **Test on real devices**: Verify format delivery
|
||||
5. **Monitor bandwidth**: Check Cloudflare Analytics
|
||||
6. **Use WebP for animations**: Smaller than GIF
|
||||
7. **Progressive JPEG for photos**: Better perceived load time
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
- **Supported Formats**: https://developers.cloudflare.com/images/transform-images/#supported-formats-and-limitations
|
||||
274
references/responsive-images-patterns.md
Normal file
274
references/responsive-images-patterns.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Responsive Images Patterns
|
||||
|
||||
Complete guide to serving optimal images for different devices and screen sizes.
|
||||
|
||||
---
|
||||
|
||||
## srcset with Named Variants
|
||||
|
||||
Best for consistent, predefined sizes.
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/mobile 480w,
|
||||
https://imagedelivery.net/HASH/ID/tablet 768w,
|
||||
https://imagedelivery.net/HASH/ID/desktop 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/HASH/ID/desktop"
|
||||
alt="Responsive image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
**Variants to create**:
|
||||
- `mobile`: width=480, fit=scale-down
|
||||
- `tablet`: width=768, fit=scale-down
|
||||
- `desktop`: width=1920, fit=scale-down
|
||||
|
||||
---
|
||||
|
||||
## srcset with Flexible Variants
|
||||
|
||||
Best for dynamic sizing (public images only).
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Responsive image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Art Direction (Different Crops)
|
||||
|
||||
Serve different image crops for mobile vs desktop.
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<!-- Mobile: Square crop -->
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/mobile-square"
|
||||
/>
|
||||
|
||||
<!-- Desktop: Wide crop -->
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/desktop-wide"
|
||||
/>
|
||||
|
||||
<!-- Fallback -->
|
||||
<img
|
||||
src="https://imagedelivery.net/HASH/ID/desktop-wide"
|
||||
alt="Art directed image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
```
|
||||
|
||||
**Variants to create**:
|
||||
- `mobile-square`: width=480, height=480, fit=cover
|
||||
- `desktop-wide`: width=1920, height=1080, fit=cover
|
||||
|
||||
---
|
||||
|
||||
## High-DPI (Retina) Displays
|
||||
|
||||
Serve 2x images for high-resolution screens.
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=400,dpr=1,f=auto 1x,
|
||||
https://imagedelivery.net/HASH/ID/w=400,dpr=2,f=auto 2x
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=400,f=auto"
|
||||
alt="Retina-ready image"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blur Placeholder (LQIP)
|
||||
|
||||
Load tiny blurred placeholder first, then swap to full image.
|
||||
|
||||
```html
|
||||
<img
|
||||
id="lqip-image"
|
||||
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
|
||||
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Image with LQIP"
|
||||
style="filter: blur(10px); transition: filter 0.3s;"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const img = document.getElementById('lqip-image');
|
||||
const fullSrc = img.getAttribute('data-src');
|
||||
|
||||
const fullImg = new Image();
|
||||
fullImg.src = fullSrc;
|
||||
fullImg.onload = () => {
|
||||
img.src = fullSrc;
|
||||
img.style.filter = 'blur(0)';
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Defer loading below-the-fold images.
|
||||
|
||||
```html
|
||||
<!-- Native lazy loading (modern browsers) -->
|
||||
<img src="..." loading="lazy" alt="..." />
|
||||
|
||||
<!-- With Intersection Observer (better control) -->
|
||||
<img
|
||||
class="lazy"
|
||||
data-src="https://imagedelivery.net/HASH/ID/w=800,f=auto"
|
||||
alt="Lazy loaded image"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL Transformations (/cdn-cgi/image/)
|
||||
|
||||
Transform ANY publicly accessible image (not just Cloudflare Images storage).
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
|
||||
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
|
||||
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg"
|
||||
alt="Transformed origin image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Breakpoints
|
||||
|
||||
```javascript
|
||||
const breakpoints = {
|
||||
mobile: 480, // Small phones
|
||||
tablet: 768, // Tablets
|
||||
desktop: 1024, // Laptops
|
||||
wide: 1920, // Desktops
|
||||
ultrawide: 2560 // Large displays
|
||||
};
|
||||
```
|
||||
|
||||
**sizes attribute**:
|
||||
```html
|
||||
sizes="
|
||||
(max-width: 480px) 480px,
|
||||
(max-width: 768px) 768px,
|
||||
(max-width: 1024px) 1024px,
|
||||
(max-width: 1920px) 1920px,
|
||||
2560px
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hero image with art direction -->
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/w=480,h=480,fit=cover,f=auto"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
|
||||
/>
|
||||
<img
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
|
||||
alt="Hero image"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<!-- Responsive gallery images -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/HASH/ID/w=1024,f=auto 1024w
|
||||
"
|
||||
sizes="
|
||||
(max-width: 480px) 100vw,
|
||||
(max-width: 768px) 50vw,
|
||||
33vw
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1024,f=auto"
|
||||
alt="Gallery image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use format=auto**: Optimal WebP/AVIF delivery
|
||||
2. **Add loading="lazy"**: Below-the-fold images
|
||||
3. **Match sizes to CSS layout**: Use `sizes` attribute correctly
|
||||
4. **Provide descriptive alt text**: Accessibility
|
||||
5. **Use LQIP for perceived performance**: Better UX
|
||||
6. **Named variants for private**: Signed URLs compatible
|
||||
7. **Flexible variants for public**: Dynamic sizing
|
||||
8. **Limit srcset to 3-5 sizes**: Balance performance vs flexibility
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Responsive Images (MDN)**: https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
|
||||
- **Cloudflare Images**: https://developers.cloudflare.com/images/
|
||||
350
references/signed-urls-guide.md
Normal file
350
references/signed-urls-guide.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Signed URLs Guide
|
||||
|
||||
Complete guide to generating signed URLs for private images using HMAC-SHA256.
|
||||
|
||||
---
|
||||
|
||||
## What Are Signed URLs?
|
||||
|
||||
Time-limited URLs for serving private images securely.
|
||||
|
||||
**Format**:
|
||||
```
|
||||
https://imagedelivery.net/<HASH>/<ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- User profile photos (private until shared)
|
||||
- Paid content (time-limited access)
|
||||
- Temporary downloads
|
||||
- Secure image delivery
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Upload with signed URLs enabled**:
|
||||
```javascript
|
||||
await uploadImage(file, {
|
||||
requireSignedURLs: true // Image requires signed URL
|
||||
});
|
||||
```
|
||||
|
||||
2. **Get signing key**:
|
||||
Dashboard → Images → Keys → Generate key
|
||||
|
||||
3. **Use named variants only**:
|
||||
Flexible variants NOT compatible with signed URLs.
|
||||
|
||||
---
|
||||
|
||||
## Signature Algorithm (HMAC-SHA256)
|
||||
|
||||
### String to Sign
|
||||
|
||||
```
|
||||
{imageId}{variant}{expiry}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
Image ID: abc123
|
||||
Variant: public
|
||||
Expiry: 1735228800
|
||||
|
||||
String to sign: abc123public1735228800
|
||||
```
|
||||
|
||||
### Generate Signature
|
||||
|
||||
**Workers** (recommended):
|
||||
```typescript
|
||||
async function generateSignature(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiry: number,
|
||||
signingKey: string
|
||||
): Promise<string> {
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(signingKey);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
|
||||
// Convert to hex string
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
```
|
||||
|
||||
**Node.js**:
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateSignature(imageId, variant, expiry, signingKey) {
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', signingKey)
|
||||
.update(stringToSign)
|
||||
.digest('hex');
|
||||
}
|
||||
```
|
||||
|
||||
### Build Signed URL
|
||||
|
||||
```typescript
|
||||
async function generateSignedURL(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expirySeconds: number,
|
||||
accountHash: string,
|
||||
signingKey: string
|
||||
): Promise<string> {
|
||||
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
|
||||
const sig = await generateSignature(imageId, variant, expiry, signingKey);
|
||||
|
||||
return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expiry Timestamp
|
||||
|
||||
**Unix timestamp** (seconds since epoch):
|
||||
```typescript
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHour = 60 * 60;
|
||||
const expiry = now + oneHour; // 1 hour from now
|
||||
```
|
||||
|
||||
**From specific date**:
|
||||
```typescript
|
||||
const expiryDate = new Date('2025-10-27T18:00:00Z');
|
||||
const expiry = Math.floor(expiryDate.getTime() / 1000);
|
||||
```
|
||||
|
||||
**Common presets**:
|
||||
```typescript
|
||||
const expiryPresets = {
|
||||
fiveMinutes: 5 * 60,
|
||||
fifteenMinutes: 15 * 60,
|
||||
oneHour: 60 * 60,
|
||||
oneDay: 24 * 60 * 60,
|
||||
oneWeek: 7 * 24 * 60 * 60
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example (Workers)
|
||||
|
||||
```typescript
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_HASH: string;
|
||||
IMAGES_SIGNING_KEY: string;
|
||||
}
|
||||
|
||||
async function generateSignedURL(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expirySeconds: number,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiry = now + expirySeconds;
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
const sig = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// Generate signed URL valid for 1 hour
|
||||
const signedURL = await generateSignedURL(
|
||||
'image-id',
|
||||
'public',
|
||||
3600,
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json({ signedURL });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple Variants
|
||||
|
||||
Generate signed URLs for multiple variants at once:
|
||||
|
||||
```typescript
|
||||
async function generateSignedURLsForVariants(
|
||||
imageId: string,
|
||||
variants: string[],
|
||||
expirySeconds: number,
|
||||
env: Env
|
||||
): Promise<Record<string, string>> {
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const variant of variants) {
|
||||
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const urls = await generateSignedURLsForVariants(
|
||||
'image-id',
|
||||
['thumbnail', 'medium', 'large'],
|
||||
3600,
|
||||
env
|
||||
);
|
||||
|
||||
// {
|
||||
// thumbnail: 'https://imagedelivery.net/.../thumbnail?exp=...&sig=...',
|
||||
// medium: 'https://imagedelivery.net/.../medium?exp=...&sig=...',
|
||||
// large: 'https://imagedelivery.net/.../large?exp=...&sig=...'
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (Cloudflare handles this)
|
||||
|
||||
For reference, here's how verification works:
|
||||
|
||||
```typescript
|
||||
async function verifySignature(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiry: number,
|
||||
providedSig: string,
|
||||
signingKey: string
|
||||
): Promise<boolean> {
|
||||
// Check if expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (expiry < now) {
|
||||
return false; // Expired
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
const expectedSig = await generateSignature(imageId, variant, expiry, signingKey);
|
||||
|
||||
return expectedSig === providedSig;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. Signed URL returns 403
|
||||
|
||||
**Causes**:
|
||||
- Image not uploaded with `requireSignedURLs=true`
|
||||
- Signature incorrect (wrong signing key)
|
||||
- URL expired
|
||||
- Using flexible variants (not supported)
|
||||
|
||||
**Solutions**:
|
||||
- Verify image requires signed URLs
|
||||
- Check signing key matches dashboard
|
||||
- Ensure expiry in future
|
||||
- Use named variants only
|
||||
|
||||
### 2. Signature doesn't match
|
||||
|
||||
**Causes**:
|
||||
- Wrong signing key
|
||||
- Incorrect string-to-sign format
|
||||
- Timestamp precision (must be seconds, not milliseconds)
|
||||
|
||||
**Solutions**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Seconds
|
||||
const expiry = Math.floor(Date.now() / 1000);
|
||||
|
||||
// ❌ WRONG - Milliseconds
|
||||
const expiry = Date.now();
|
||||
```
|
||||
|
||||
### 3. Cannot use with flexible variants
|
||||
|
||||
**Error**: 403 Forbidden when using flexible variants with signed URLs
|
||||
|
||||
**Solution**: Use named variants for private images
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const url = await generateSignedURL('id', 'thumbnail', 3600, env);
|
||||
|
||||
// ❌ WRONG
|
||||
const url = `https://imagedelivery.net/${hash}/${id}/w=300?exp=${exp}&sig=${sig}`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Keep signing key secret**: Never expose in client-side code
|
||||
2. **Generate on backend**: Frontend requests signed URL from backend
|
||||
3. **Short expiry for sensitive content**: 5-15 minutes for temporary access
|
||||
4. **Longer expiry for user content**: 1-24 hours for profile photos
|
||||
5. **Rotate keys periodically**: Dashboard → Images → Keys → Regenerate
|
||||
6. **Log suspicious activity**: Monitor for signature mismatches
|
||||
|
||||
---
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Profile Photos (24-hour expiry)
|
||||
```typescript
|
||||
const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env);
|
||||
```
|
||||
|
||||
### Temporary Download (5 minutes)
|
||||
```typescript
|
||||
const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env);
|
||||
```
|
||||
|
||||
### Paid Content (1-week subscription)
|
||||
```typescript
|
||||
const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
|
||||
476
references/top-errors.md
Normal file
476
references/top-errors.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Top Errors and Solutions
|
||||
|
||||
Complete troubleshooting guide for all documented Cloudflare Images errors.
|
||||
|
||||
---
|
||||
|
||||
## Direct Creator Upload Errors
|
||||
|
||||
### 1. CORS Error - Content-Type Not Allowed
|
||||
|
||||
**Error**:
|
||||
```
|
||||
Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers
|
||||
```
|
||||
|
||||
**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114)
|
||||
|
||||
**Why It Happens**:
|
||||
Server CORS settings only allow `multipart/form-data` for Content-Type header.
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // Browser sets multipart/form-data automatically
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
await fetch(uploadURL, {
|
||||
headers: { 'Content-Type': 'application/json' }, // CORS error
|
||||
body: JSON.stringify({ file: base64Image })
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use FormData API
|
||||
- Let browser set Content-Type header (don't set manually)
|
||||
- Name field `file` (not `image` or other)
|
||||
|
||||
---
|
||||
|
||||
### 2. Error 5408 - Upload Timeout
|
||||
|
||||
**Error**: `Error 5408` after ~15 seconds
|
||||
|
||||
**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336)
|
||||
|
||||
**Why It Happens**:
|
||||
Cloudflare has 30-second request timeout. Slow uploads or large files exceed limit.
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert('File too large. Please select an image under 10MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compress image before upload (optional)
|
||||
async function compressImage(file) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = await createImageBitmap(file);
|
||||
|
||||
const maxWidth = 4000;
|
||||
const scale = Math.min(1, maxWidth / img.width);
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.9);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Limit file size (10MB max recommended)
|
||||
- Compress images client-side if needed
|
||||
- Show upload progress to user
|
||||
- Handle timeout errors gracefully
|
||||
|
||||
---
|
||||
|
||||
### 3. Error 400 - Invalid File Parameter
|
||||
|
||||
**Error**: `400 Bad Request` with unhelpful message
|
||||
|
||||
**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629)
|
||||
|
||||
**Why It Happens**:
|
||||
File field must be named `file` (not `image`, `photo`, etc.).
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
formData.append('file', imageFile);
|
||||
|
||||
// ❌ WRONG
|
||||
formData.append('image', imageFile); // 400 error
|
||||
formData.append('photo', imageFile); // 400 error
|
||||
formData.append('upload', imageFile); // 400 error
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always name the field `file`
|
||||
- Check FormData contents before sending
|
||||
|
||||
---
|
||||
|
||||
### 4. CORS Preflight Failures
|
||||
|
||||
**Error**: Preflight OPTIONS request blocked
|
||||
|
||||
**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805)
|
||||
|
||||
**Why It Happens**:
|
||||
Calling `/direct_upload` API directly from browser (should be backend-only).
|
||||
|
||||
**Solution**:
|
||||
```
|
||||
CORRECT ARCHITECTURE:
|
||||
|
||||
Browser → POST /api/upload-url → Backend
|
||||
↓
|
||||
POST /direct_upload → Cloudflare API
|
||||
↓
|
||||
Backend ← Returns uploadURL ← Cloudflare API
|
||||
↓
|
||||
Browser receives uploadURL
|
||||
↓
|
||||
Browser → Uploads to uploadURL → Cloudflare (direct upload)
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Never expose API token to browser
|
||||
- Generate upload URL on backend
|
||||
- Return uploadURL to frontend
|
||||
- Frontend uploads to uploadURL (not /direct_upload)
|
||||
|
||||
---
|
||||
|
||||
## Image Transformation Errors
|
||||
|
||||
### 5. Error 9401 - Invalid Arguments
|
||||
|
||||
**Error**: `Cf-Resized: err=9401` in response headers
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Missing required `cf.image` parameters or invalid values.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 'large', // Must be number
|
||||
quality: 150, // Max 100
|
||||
format: 'invalid' // Must be valid format
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate all parameters
|
||||
- Use TypeScript for type checking
|
||||
- Check official docs for valid ranges
|
||||
|
||||
---
|
||||
|
||||
### 6. Error 9402 - Image Too Large
|
||||
|
||||
**Error**: `Cf-Resized: err=9402`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Image exceeds maximum area or download fails.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Check image dimensions before transforming
|
||||
const response = await fetch(imageURL, { method: 'HEAD' });
|
||||
// Or fetch and check after
|
||||
const img = await fetch(imageURL);
|
||||
// Validate size
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate source image dimensions
|
||||
- Max 100 megapixels (e.g., 10000x10000px)
|
||||
- Use reasonable source images
|
||||
|
||||
---
|
||||
|
||||
### 7. Error 9403 - Request Loop
|
||||
|
||||
**Error**: `Cf-Resized: err=9403`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Worker fetching its own URL or already-resized image.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Fetch external origin
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const originURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(originURL, {
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ WRONG - Fetches worker's own URL (loop)
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return fetch(request, { // Fetches self
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always fetch external origin
|
||||
- Don't transform already-transformed images
|
||||
- Check URL routing logic
|
||||
|
||||
---
|
||||
|
||||
### 8. Error 9406/9419 - Invalid URL Format
|
||||
|
||||
**Error**: `Cf-Resized: err=9406` or `err=9419`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Image URL uses HTTP (not HTTPS) or contains spaces/unescaped Unicode.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const filename = "photo name.jpg";
|
||||
const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`;
|
||||
|
||||
// ❌ WRONG
|
||||
const imageURL = "http://example.com/image.jpg"; // HTTP not allowed
|
||||
const imageURL = "https://example.com/photo name.jpg"; // Space not encoded
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always use HTTPS (HTTP not supported)
|
||||
- URL-encode all paths with `encodeURIComponent()`
|
||||
- No spaces or unescaped Unicode in URLs
|
||||
|
||||
---
|
||||
|
||||
### 9. Error 9412 - Non-Image Response
|
||||
|
||||
**Error**: `Cf-Resized: err=9412`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Origin server returns HTML (e.g., 404 page) instead of image.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Verify URL before transforming
|
||||
const originResponse = await fetch(imageURL, { method: 'HEAD' });
|
||||
const contentType = originResponse.headers.get('content-type');
|
||||
|
||||
if (!contentType?.startsWith('image/')) {
|
||||
return new Response('Not an image', { status: 400 });
|
||||
}
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Verify origin returns image (check Content-Type)
|
||||
- Handle 404s before transforming
|
||||
- Validate image URLs
|
||||
|
||||
---
|
||||
|
||||
### 10. Error 9413 - Max Image Area Exceeded
|
||||
|
||||
**Error**: `Cf-Resized: err=9413`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Source image exceeds 100 megapixels (e.g., 10000x10000px).
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
const MAX_MEGAPIXELS = 100;
|
||||
|
||||
if (width * height > MAX_MEGAPIXELS * 1_000_000) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate image dimensions before transforming
|
||||
- Pre-process oversized images
|
||||
- Reject images above threshold (100 megapixels)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Errors
|
||||
|
||||
### 11. Flexible Variants + Signed URLs Incompatibility
|
||||
|
||||
**Error**: Flexible variants don't work with private images
|
||||
|
||||
**Source**: [Cloudflare Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/)
|
||||
|
||||
**Why It Happens**:
|
||||
Flexible variants cannot be used with `requireSignedURLs=true`.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use named variants for private images
|
||||
await uploadImage({
|
||||
file: imageFile,
|
||||
requireSignedURLs: true // Use named variants: /public, /avatar, etc.
|
||||
});
|
||||
|
||||
// ❌ WRONG - Flexible variants don't support signed URLs
|
||||
// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use named variants for private images
|
||||
- Use flexible variants for public images only
|
||||
|
||||
---
|
||||
|
||||
### 12. SVG Resizing Limitation
|
||||
|
||||
**Error**: SVG files don't resize via transformations
|
||||
|
||||
**Source**: [Cloudflare Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files)
|
||||
|
||||
**Why It Happens**:
|
||||
SVG is vector format (inherently scalable), resizing not applicable.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// SVGs can be served but not resized
|
||||
// Use any variant name as placeholder
|
||||
// https://imagedelivery.net/<HASH>/<SVG_ID>/public
|
||||
|
||||
// SVG will be served at original size regardless of variant settings
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Don't try to resize SVGs
|
||||
- Serve SVGs as-is
|
||||
- Use variants as placeholders
|
||||
|
||||
---
|
||||
|
||||
### 13. EXIF Metadata Stripped by Default
|
||||
|
||||
**Error**: GPS data, camera settings removed from uploaded JPEGs
|
||||
|
||||
**Source**: [Cloudflare Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata)
|
||||
|
||||
**Why It Happens**:
|
||||
Default behavior strips all metadata except copyright.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Preserve metadata
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
metadata: 'keep' // Options: 'none', 'copyright', 'keep'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use `metadata=keep` if preservation needed
|
||||
- Default `copyright` for JPEG
|
||||
- Color profiles and EXIF rotation always applied
|
||||
|
||||
---
|
||||
|
||||
## General Troubleshooting
|
||||
|
||||
### Images not transforming
|
||||
|
||||
**Symptoms**: `/cdn-cgi/image/...` returns original or 404
|
||||
|
||||
**Solutions**:
|
||||
1. Enable transformations: Dashboard → Images → Transformations → Enable
|
||||
2. Verify zone proxied (orange cloud)
|
||||
3. Check source image accessible
|
||||
4. Wait 5-10 minutes for propagation
|
||||
|
||||
### Signed URLs returning 403
|
||||
|
||||
**Symptoms**: 403 Forbidden with signed URL
|
||||
|
||||
**Solutions**:
|
||||
1. Verify image uploaded with `requireSignedURLs=true`
|
||||
2. Check signature generation (HMAC-SHA256)
|
||||
3. Ensure expiry in future
|
||||
4. Verify signing key matches dashboard
|
||||
5. Cannot use flexible variants (use named variants)
|
||||
|
||||
---
|
||||
|
||||
## Checking for Errors
|
||||
|
||||
**Response Headers**:
|
||||
```javascript
|
||||
const response = await fetch(transformedImageURL);
|
||||
const cfResized = response.headers.get('Cf-Resized');
|
||||
|
||||
if (cfResized?.includes('err=')) {
|
||||
console.error('Transformation error:', cfResized);
|
||||
}
|
||||
```
|
||||
|
||||
**Common patterns**:
|
||||
- `Cf-Resized: err=9401` - Invalid arguments
|
||||
- `Cf-Resized: err=9403` - Request loop
|
||||
- `Cf-Resized: err=9412` - Non-image response
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/
|
||||
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
337
references/transformation-options.md
Normal file
337
references/transformation-options.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Image Transformation Options
|
||||
|
||||
Complete reference for all image transformation parameters.
|
||||
|
||||
Works with:
|
||||
- **URL format**: `/cdn-cgi/image/<OPTIONS>/<SOURCE>`
|
||||
- **Workers format**: `fetch(url, { cf: { image: {...} } })`
|
||||
|
||||
---
|
||||
|
||||
## Sizing
|
||||
|
||||
### width
|
||||
Max width in pixels.
|
||||
|
||||
**URL**: `width=800` or `w=800`
|
||||
**Workers**: `{ width: 800 }`
|
||||
**Range**: 1-10000
|
||||
|
||||
### height
|
||||
Max height in pixels.
|
||||
|
||||
**URL**: `height=600` or `h=600`
|
||||
**Workers**: `{ height: 600 }`
|
||||
**Range**: 1-10000
|
||||
|
||||
### dpr
|
||||
Device pixel ratio for high-DPI displays.
|
||||
|
||||
**URL**: `dpr=2`
|
||||
**Workers**: `{ dpr: 2 }`
|
||||
**Range**: 1-3
|
||||
**Example**: `dpr=2` serves 2x size for Retina displays
|
||||
|
||||
---
|
||||
|
||||
## Fit Modes
|
||||
|
||||
### fit
|
||||
How to resize image.
|
||||
|
||||
**Options**:
|
||||
- `scale-down`: Shrink to fit (never enlarge)
|
||||
- `contain`: Resize to fit within dimensions (preserve aspect ratio)
|
||||
- `cover`: Resize to fill dimensions (may crop)
|
||||
- `crop`: Crop to exact dimensions
|
||||
- `pad`: Resize and add padding
|
||||
|
||||
**URL**: `fit=cover`
|
||||
**Workers**: `{ fit: 'cover' }`
|
||||
**Default**: `scale-down`
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
fit=scale-down: 800x600 image → max 400x300 → 400x300 (scaled down)
|
||||
fit=scale-down: 400x300 image → max 800x600 → 400x300 (not enlarged)
|
||||
fit=contain: Any size → 800x600 → Fits inside box, preserves aspect
|
||||
fit=cover: Any size → 800x600 → Fills box, may crop edges
|
||||
fit=crop: Any size → 800x600 → Exact size, crops as needed
|
||||
fit=pad: 800x600 image → 1000x1000 → 800x600 + padding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality & Format
|
||||
|
||||
### quality
|
||||
JPEG/WebP quality.
|
||||
|
||||
**URL**: `quality=85` or `q=85`
|
||||
**Workers**: `{ quality: 85 }`
|
||||
**Range**: 1-100
|
||||
**Default**: 85
|
||||
**Recommended**: 80-90 for photos, 90-100 for graphics
|
||||
|
||||
### format
|
||||
Output format.
|
||||
|
||||
**Options**:
|
||||
- `auto`: Serve AVIF → WebP → Original based on browser support
|
||||
- `avif`: Always AVIF (with WebP fallback)
|
||||
- `webp`: Always WebP
|
||||
- `jpeg`: JPEG (progressive)
|
||||
- `baseline-jpeg`: JPEG (baseline, for older devices)
|
||||
- `json`: Image metadata instead of image
|
||||
|
||||
**URL**: `format=auto` or `f=auto`
|
||||
**Workers**: `{ format: 'auto' }`
|
||||
**Default**: Original format
|
||||
**Recommended**: `format=auto` for optimal delivery
|
||||
|
||||
### compression
|
||||
WebP compression mode.
|
||||
|
||||
**Options**:
|
||||
- `fast`: Faster encoding, larger file
|
||||
- `lossless`: No quality loss
|
||||
|
||||
**URL**: `compression=fast`
|
||||
**Workers**: `{ compression: 'fast' }`
|
||||
|
||||
---
|
||||
|
||||
## Cropping
|
||||
|
||||
### gravity
|
||||
Crop focal point.
|
||||
|
||||
**Options**:
|
||||
- `auto`: Smart crop based on saliency
|
||||
- `face`: Crop to detected face
|
||||
- `left`, `right`, `top`, `bottom`: Crop to side
|
||||
- `XxY`: Coordinates (e.g., `0.5x0.5` for center)
|
||||
|
||||
**URL**: `gravity=face` or `gravity=0.5x0.3`
|
||||
**Workers**: `{ gravity: 'face' }` or `{ gravity: { x: 0.5, y: 0.3 } }`
|
||||
**Default**: `auto`
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
gravity=auto: Smart crop to interesting area
|
||||
gravity=face: Crop to detected face (if found)
|
||||
gravity=0.5x0.5: Center crop
|
||||
gravity=0x0: Top-left corner
|
||||
gravity=1x1: Bottom-right corner
|
||||
```
|
||||
|
||||
### zoom
|
||||
Face cropping zoom level (when `gravity=face`).
|
||||
|
||||
**URL**: `zoom=0.5`
|
||||
**Workers**: `{ zoom: 0.5 }`
|
||||
**Range**: 0-1
|
||||
**Default**: 0
|
||||
**Behavior**: `0` = include background, `1` = crop close to face
|
||||
|
||||
### trim
|
||||
Remove border (pixels to trim from edges).
|
||||
|
||||
**URL**: `trim=10`
|
||||
**Workers**: `{ trim: 10 }`
|
||||
**Range**: 0-100
|
||||
|
||||
---
|
||||
|
||||
## Effects
|
||||
|
||||
### blur
|
||||
Gaussian blur radius.
|
||||
|
||||
**URL**: `blur=20`
|
||||
**Workers**: `{ blur: 20 }`
|
||||
**Range**: 1-250
|
||||
**Use cases**: Privacy, background blur, LQIP placeholders
|
||||
|
||||
### sharpen
|
||||
Sharpen intensity.
|
||||
|
||||
**URL**: `sharpen=3`
|
||||
**Workers**: `{ sharpen: 3 }`
|
||||
**Range**: 0-10
|
||||
**Recommended**: 1-3 for subtle sharpening
|
||||
|
||||
### brightness
|
||||
Brightness adjustment.
|
||||
|
||||
**URL**: `brightness=1.2`
|
||||
**Workers**: `{ brightness: 1.2 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
**Examples**: `0.5` = darker, `1.5` = brighter
|
||||
|
||||
### contrast
|
||||
Contrast adjustment.
|
||||
|
||||
**URL**: `contrast=1.1`
|
||||
**Workers**: `{ contrast: 1.1 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
|
||||
### gamma
|
||||
Gamma correction.
|
||||
|
||||
**URL**: `gamma=1.5`
|
||||
**Workers**: `{ gamma: 1.5 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
**Note**: `0` is ignored
|
||||
|
||||
---
|
||||
|
||||
## Rotation & Flipping
|
||||
|
||||
### rotate
|
||||
Rotate image.
|
||||
|
||||
**Options**: `0`, `90`, `180`, `270`
|
||||
|
||||
**URL**: `rotate=90`
|
||||
**Workers**: `{ rotate: 90 }`
|
||||
|
||||
### flip
|
||||
Flip image.
|
||||
|
||||
**Options**:
|
||||
- `h`: Horizontal flip
|
||||
- `v`: Vertical flip
|
||||
- `hv`: Both horizontal and vertical
|
||||
|
||||
**URL**: `flip=h`
|
||||
**Workers**: `{ flip: 'h' }`
|
||||
|
||||
**Note**: Flipping is performed BEFORE rotation.
|
||||
|
||||
---
|
||||
|
||||
## Other
|
||||
|
||||
### background
|
||||
Background color for transparency or padding.
|
||||
|
||||
**URL**: `background=rgb(255 0 0)` (CSS4 syntax)
|
||||
**Workers**: `{ background: 'rgb(255 0 0)' }`
|
||||
**Examples**:
|
||||
- `background=white`
|
||||
- `background=rgb(255 255 255)`
|
||||
- `background=rgba(255 255 255 50)`
|
||||
|
||||
**Use with**: `fit=pad` or transparent images (PNG, WebP)
|
||||
|
||||
### metadata
|
||||
EXIF metadata handling.
|
||||
|
||||
**Options**:
|
||||
- `none`: Strip all metadata
|
||||
- `copyright`: Keep only copyright tag
|
||||
- `keep`: Preserve most EXIF metadata
|
||||
|
||||
**URL**: `metadata=keep`
|
||||
**Workers**: `{ metadata: 'keep' }`
|
||||
**Default**: `copyright` for JPEG, `none` for others
|
||||
|
||||
**Note**: Color profiles and EXIF rotation always applied, even if metadata stripped.
|
||||
|
||||
### anim
|
||||
Preserve animation frames (GIF, WebP).
|
||||
|
||||
**URL**: `anim=false`
|
||||
**Workers**: `{ anim: false }`
|
||||
**Default**: `true`
|
||||
|
||||
**Use case**: Converting animated GIF to still image
|
||||
|
||||
---
|
||||
|
||||
## Combining Options
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
/cdn-cgi/image/width=800,height=600,fit=cover,quality=85,format=auto/image.jpg
|
||||
```
|
||||
|
||||
**Workers Format**:
|
||||
```javascript
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
fit: 'cover',
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Presets
|
||||
|
||||
### Thumbnail
|
||||
```
|
||||
width=300,height=300,fit=cover,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Avatar
|
||||
```
|
||||
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Hero
|
||||
```
|
||||
width=1920,height=1080,fit=cover,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Blur Placeholder (LQIP)
|
||||
```
|
||||
width=50,quality=10,blur=20,format=webp
|
||||
```
|
||||
|
||||
### Product Image
|
||||
```
|
||||
width=800,height=800,fit=contain,sharpen=2,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Mobile)
|
||||
```
|
||||
width=480,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Tablet)
|
||||
```
|
||||
width=768,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Desktop)
|
||||
```
|
||||
width=1920,quality=85,format=auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
- **Max dimensions**: 10,000 x 10,000 pixels
|
||||
- **Max area**: 100 megapixels
|
||||
- **Max file size**: No published limit (but 10MB recommended)
|
||||
- **Quality range**: 1-100
|
||||
- **DPR range**: 1-3
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
264
references/variants-guide.md
Normal file
264
references/variants-guide.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Variants Guide - Named vs Flexible
|
||||
|
||||
Complete guide to Cloudflare Images variants.
|
||||
|
||||
---
|
||||
|
||||
## What Are Variants?
|
||||
|
||||
Variants define how images should be resized and transformed for different use cases.
|
||||
|
||||
**Two Types**:
|
||||
1. **Named Variants** - Predefined transformations (up to 100)
|
||||
2. **Flexible Variants** - Dynamic transformations (unlimited)
|
||||
|
||||
---
|
||||
|
||||
## Named Variants
|
||||
|
||||
### Overview
|
||||
|
||||
Pre-configured transformations that apply consistently across all images.
|
||||
|
||||
**Limits**: 100 variants per account
|
||||
**Use with**: Public and private images (signed URLs compatible)
|
||||
|
||||
### Creating Named Variants
|
||||
|
||||
**Via API**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{
|
||||
"id": "thumbnail",
|
||||
"options": {
|
||||
"fit": "cover",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"metadata": "none"
|
||||
},
|
||||
"neverRequireSignedURLs": false
|
||||
}'
|
||||
```
|
||||
|
||||
**Via Dashboard**:
|
||||
1. Dashboard → Images → Variants
|
||||
2. Create variant
|
||||
3. Set dimensions, fit mode, metadata handling
|
||||
|
||||
### Using Named Variants
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT_NAME>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<img src="https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/abc123/thumbnail" />
|
||||
```
|
||||
|
||||
### Common Named Variants
|
||||
|
||||
```javascript
|
||||
const presets = {
|
||||
thumbnail: { width: 300, height: 300, fit: 'cover' },
|
||||
avatar: { width: 200, height: 200, fit: 'cover' },
|
||||
small: { width: 480, fit: 'scale-down' },
|
||||
medium: { width: 768, fit: 'scale-down' },
|
||||
large: { width: 1920, fit: 'scale-down' },
|
||||
hero: { width: 1920, height: 1080, fit: 'cover' },
|
||||
product: { width: 800, height: 800, fit: 'contain' }
|
||||
};
|
||||
```
|
||||
|
||||
### When to Use Named Variants
|
||||
|
||||
✅ **Use when**:
|
||||
- Consistent sizes needed across app
|
||||
- Private images (signed URLs required)
|
||||
- Predictable, simple URLs
|
||||
- Team collaboration (shared definitions)
|
||||
|
||||
❌ **Don't use when**:
|
||||
- Need dynamic sizing per request
|
||||
- Rapid prototyping with many sizes
|
||||
- Approaching 100-variant limit
|
||||
|
||||
---
|
||||
|
||||
## Flexible Variants
|
||||
|
||||
### Overview
|
||||
|
||||
Dynamic transformations using params directly in URL.
|
||||
|
||||
**Limits**: Unlimited transformations
|
||||
**Use with**: Public images only (signed URLs NOT compatible)
|
||||
|
||||
### Enabling Flexible Variants
|
||||
|
||||
**One-time setup per account**:
|
||||
```bash
|
||||
curl --request PATCH \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/config \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"flexible_variants": true}'
|
||||
```
|
||||
|
||||
### Using Flexible Variants
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<TRANSFORMATION_PARAMS>
|
||||
```
|
||||
|
||||
**Examples**:
|
||||
```html
|
||||
<!-- Basic resize -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=400,h=300" />
|
||||
|
||||
<!-- With quality and format -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto" />
|
||||
|
||||
<!-- Sharpen and crop -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=600,h=600,fit=cover,sharpen=3" />
|
||||
|
||||
<!-- Blur effect -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=500,blur=20,q=50" />
|
||||
```
|
||||
|
||||
### Available Parameters
|
||||
|
||||
Same as transformation options:
|
||||
- `w`, `h` - Width, height
|
||||
- `fit` - Fit mode (scale-down, contain, cover, crop, pad)
|
||||
- `q` - Quality (1-100)
|
||||
- `f` - Format (auto, avif, webp, jpeg)
|
||||
- `gravity` - Crop focal point (auto, face, left, right, top, bottom)
|
||||
- `blur`, `sharpen`, `brightness`, `contrast`, `gamma`
|
||||
- `rotate`, `flip`
|
||||
- `dpr` - Device pixel ratio
|
||||
- `metadata` - EXIF handling (none, copyright, keep)
|
||||
- `anim` - Preserve animation (true/false)
|
||||
|
||||
### When to Use Flexible Variants
|
||||
|
||||
✅ **Use when**:
|
||||
- Dynamic sizing needs
|
||||
- Public images only
|
||||
- Rapid prototyping
|
||||
- User-controlled transformations
|
||||
|
||||
❌ **Don't use when**:
|
||||
- Need signed URLs (private images)
|
||||
- Want consistent, predictable URLs
|
||||
- Team needs shared definitions
|
||||
|
||||
---
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | Named Variants | Flexible Variants |
|
||||
|---------|---------------|-------------------|
|
||||
| **Limit** | 100 per account | Unlimited |
|
||||
| **Signed URLs** | ✅ Compatible | ❌ Not compatible |
|
||||
| **URL Format** | `/thumbnail` | `/w=400,h=300,fit=cover` |
|
||||
| **URL Length** | Short, clean | Longer, dynamic |
|
||||
| **Setup** | Create variants first | Enable once, use anywhere |
|
||||
| **Use Case** | Consistent sizes | Dynamic sizing |
|
||||
| **Team Sharing** | Shared definitions | Ad-hoc transformations |
|
||||
| **Private Images** | ✅ Supported | ❌ Public only |
|
||||
|
||||
---
|
||||
|
||||
## Combining Both
|
||||
|
||||
You can use both types in the same account:
|
||||
|
||||
```html
|
||||
<!-- Named variant for avatar (private image, signed URL) -->
|
||||
<img src="https://imagedelivery.net/HASH/PRIVATE_ID/avatar?exp=123&sig=abc" />
|
||||
|
||||
<!-- Flexible variant for public thumbnail -->
|
||||
<img src="https://imagedelivery.net/HASH/PUBLIC_ID/w=300,h=300,fit=cover" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Named Variants
|
||||
|
||||
1. **Create core sizes first**: thumbnail, small, medium, large
|
||||
2. **Use descriptive names**: `product-square`, `hero-wide`, `avatar-round`
|
||||
3. **Document variant usage**: Share definitions with team
|
||||
4. **Set consistent quality**: 85 for photos, 90+ for graphics
|
||||
5. **Use `metadata: none`**: Unless specific need to preserve EXIF
|
||||
|
||||
### For Flexible Variants
|
||||
|
||||
1. **Always use `f=auto`**: Optimal format for each browser
|
||||
2. **Limit dynamic range**: Don't allow arbitrary sizes (performance)
|
||||
3. **Cache popular sizes**: Create named variants for common sizes
|
||||
4. **URL-encode params**: Especially if using special characters
|
||||
5. **Public images only**: Remember signed URL incompatibility
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### From Flexible to Named
|
||||
|
||||
If approaching flexibility limits or need signed URLs:
|
||||
|
||||
```javascript
|
||||
// Analyze usage logs
|
||||
const popularSizes = analyzeImageRequests();
|
||||
// { w=300,h=300: 50000, w=800,h=600: 30000, ... }
|
||||
|
||||
// Create named variants for top sizes
|
||||
for (const [params, count] of Object.entries(popularSizes)) {
|
||||
if (count > 10000) {
|
||||
await createVariant(getNameForParams(params), parseParams(params));
|
||||
}
|
||||
}
|
||||
|
||||
// Update URLs from flexible to named
|
||||
// Before: /w=300,h=300,fit=cover
|
||||
// After: /thumbnail
|
||||
```
|
||||
|
||||
### From Named to Flexible
|
||||
|
||||
If need more than 100 variants:
|
||||
|
||||
1. Enable flexible variants
|
||||
2. Gradually migrate to dynamic params
|
||||
3. Keep popular sizes as named variants
|
||||
4. Use flexible for long-tail sizes
|
||||
|
||||
---
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
**Named Variants**:
|
||||
- Cached at edge (fast delivery)
|
||||
- Predictable bandwidth
|
||||
- Good for high traffic
|
||||
|
||||
**Flexible Variants**:
|
||||
- Also cached at edge
|
||||
- More cache keys (potentially)
|
||||
- Good for diverse sizing needs
|
||||
|
||||
**Both**: First transformation billable, subsequent cached requests free
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
|
||||
- **Enable Flexible Variants**: https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/
|
||||
Reference in New Issue
Block a user