Implement Cloudflare Stream for video delivery and Images for image transformations. Use this skill when building media platforms, implementing video players, generating signed URLs, or optimizing image delivery with transformations.
/plugin marketplace add littlebearapps/cloudflare-engineer/plugin install cloudflare-engineer@littlebearapps-cloudflareThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Build media-rich applications using Cloudflare Stream for video and Images for image transformations. Includes patterns for signed URLs, adaptive bitrate streaming, and responsive images.
| Feature | Description | Pricing (2026) |
|---|---|---|
| Storage | $5/1,000 min stored | Per minute |
| Encoding | Included | Free |
| Delivery | $1/1,000 min viewed | Per minute watched |
| Live | $1/1,000 min live | Per minute streamed |
| Signed URLs | Included | Free |
| Feature | Description | Pricing (2026) |
|---|---|---|
| Storage | $5/100K images | Per image stored |
| Transformations | $0.50/1,000 unique | Per unique transform |
| Delivery | $1/100K images | Per image served |
| Variants | 100 named variants | Included |
// api/videos/upload.ts - Generate upload URL
interface UploadRequest {
userId: string;
maxDurationSeconds?: number;
meta?: Record<string, string>;
}
export async function createUploadUrl(
env: Env,
request: UploadRequest
): Promise<{ uploadUrl: string; videoId: string }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
maxDurationSeconds: request.maxDurationSeconds || 3600, // 1 hour default
expiry: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
requireSignedURLs: true,
allowedOrigins: ['https://your-app.com'],
meta: {
userId: request.userId,
...request.meta,
},
thumbnailTimestampPct: 0.5,
}),
}
);
const result = await response.json();
if (!result.success) {
throw new Error(result.errors[0]?.message || 'Upload creation failed');
}
return {
uploadUrl: result.result.uploadURL,
videoId: result.result.uid,
};
}
// api/videos/playback.ts - Generate signed playback URL
import { base64url } from 'rfc4648';
interface SignedUrlOptions {
videoId: string;
expiresIn?: number; // seconds
accessRules?: AccessRule[];
}
interface AccessRule {
type: 'ip.src' | 'ip.geoip.country' | 'any';
action: 'allow' | 'block';
value?: string[];
country?: string[];
}
export async function createSignedPlaybackUrl(
env: Env,
options: SignedUrlOptions
): Promise<string> {
const { videoId, expiresIn = 3600, accessRules } = options;
// Token creation using Stream's signing key
const expiry = Math.floor(Date.now() / 1000) + expiresIn;
// Build token payload
const tokenPayload = {
sub: videoId,
kid: env.STREAM_SIGNING_KEY_ID,
exp: expiry,
accessRules: accessRules || [{ type: 'any', action: 'allow' }],
};
// Sign with RSA-256 or use Cloudflare's token endpoint
const signedToken = await signStreamToken(env, tokenPayload);
// Return signed URL
return `https://customer-${env.CF_CUSTOMER_SUBDOMAIN}.cloudflarestream.com/${videoId}/manifest/video.m3u8?token=${signedToken}`;
}
// Alternative: Use Cloudflare API to generate token
async function signStreamToken(env: Env, payload: any): Promise<string> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/${payload.sub}/token`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: payload.kid,
exp: payload.exp,
accessRules: payload.accessRules,
}),
}
);
const result = await response.json();
return result.result.token;
}
<!-- Video player with Stream -->
<video id="player" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
async function loadVideo(videoId) {
// Get signed URL from your API
const response = await fetch(`/api/videos/${videoId}/playback`);
const { playbackUrl } = await response.json();
const video = document.getElementById('player');
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(playbackUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
video.src = playbackUrl;
video.addEventListener('loadedmetadata', () => video.play());
}
}
</script>
// api/webhooks/stream.ts - Handle video processing events
export async function handleStreamWebhook(
request: Request,
env: Env
): Promise<Response> {
// Verify webhook signature
const signature = request.headers.get('Webhook-Signature');
const body = await request.text();
if (!verifySignature(body, signature, env.STREAM_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
switch (event.type) {
case 'ready':
// Video is ready for playback
await handleVideoReady(env, event.payload);
break;
case 'error':
// Video processing failed
await handleVideoError(env, event.payload);
break;
case 'live_input.connected':
// Live stream started
await handleLiveStart(env, event.payload);
break;
case 'live_input.disconnected':
// Live stream ended
await handleLiveEnd(env, event.payload);
break;
}
return new Response('OK');
}
async function handleVideoReady(env: Env, payload: any) {
const { uid, duration, meta, thumbnail } = payload;
await env.DB.prepare(
`UPDATE videos SET status = 'ready', duration = ?, thumbnail_url = ?
WHERE stream_id = ?`
).bind(duration, thumbnail, uid).run();
// Notify user
if (meta?.userId) {
await sendNotification(env, meta.userId, 'Your video is ready!');
}
}
// api/images/upload.ts
export async function uploadImage(
env: Env,
file: File,
metadata: Record<string, string>
): Promise<{ imageId: string; url: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('metadata', JSON.stringify(metadata));
formData.append('requireSignedURLs', 'false');
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CF_API_TOKEN}`,
},
body: formData,
}
);
const result = await response.json();
if (!result.success) {
throw new Error(result.errors[0]?.message || 'Upload failed');
}
return {
imageId: result.result.id,
url: result.result.variants[0],
};
}
// utils/images.ts - Build transformation URLs
type ImageFit = 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png' | 'gif';
type ImageGravity = 'auto' | 'face' | 'top' | 'bottom' | 'left' | 'right' | 'center';
interface ImageTransformOptions {
width?: number;
height?: number;
fit?: ImageFit;
format?: ImageFormat;
quality?: number;
gravity?: ImageGravity;
blur?: number; // 1-250
sharpen?: number; // 0-10
brightness?: number; // -1 to 1
contrast?: number; // -1 to 1
dpr?: number; // Device pixel ratio
background?: string; // For 'pad' fit
}
export function buildImageUrl(
accountHash: string,
imageId: string,
options: ImageTransformOptions
): string {
const transforms: string[] = [];
if (options.width) transforms.push(`w=${options.width}`);
if (options.height) transforms.push(`h=${options.height}`);
if (options.fit) transforms.push(`fit=${options.fit}`);
if (options.format) transforms.push(`f=${options.format}`);
if (options.quality) transforms.push(`q=${options.quality}`);
if (options.gravity) transforms.push(`g=${options.gravity}`);
if (options.blur) transforms.push(`blur=${options.blur}`);
if (options.sharpen) transforms.push(`sharpen=${options.sharpen}`);
if (options.brightness) transforms.push(`brightness=${options.brightness}`);
if (options.contrast) transforms.push(`contrast=${options.contrast}`);
if (options.dpr) transforms.push(`dpr=${options.dpr}`);
if (options.background) transforms.push(`background=${options.background}`);
const transformString = transforms.join(',');
return `https://imagedelivery.net/${accountHash}/${imageId}/${transformString || 'public'}`;
}
// Usage examples
const thumbnailUrl = buildImageUrl(ACCOUNT_HASH, imageId, {
width: 300,
height: 200,
fit: 'cover',
format: 'webp',
quality: 80,
});
const avatarUrl = buildImageUrl(ACCOUNT_HASH, imageId, {
width: 128,
height: 128,
fit: 'cover',
gravity: 'face',
format: 'webp',
});
Define reusable transformation presets via Cloudflare Dashboard or API:
// Create named variants via API
const variants = [
{ id: 'thumbnail', fit: 'cover', width: 300, height: 200 },
{ id: 'avatar', fit: 'cover', width: 128, height: 128 },
{ id: 'hero', fit: 'cover', width: 1920, height: 1080 },
{ id: 'og', fit: 'cover', width: 1200, height: 630 }, // Open Graph
];
// Usage with named variant
const url = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/thumbnail`;
// components/ResponsiveImage.tsx
interface ResponsiveImageProps {
imageId: string;
alt: string;
sizes: string;
className?: string;
}
export function ResponsiveImage({ imageId, alt, sizes, className }: ResponsiveImageProps) {
const accountHash = process.env.CF_IMAGES_HASH;
const srcset = [320, 640, 960, 1280, 1920]
.map(w => `https://imagedelivery.net/${accountHash}/${imageId}/w=${w},f=auto ${w}w`)
.join(', ');
return (
<img
src={`https://imagedelivery.net/${accountHash}/${imageId}/w=960,f=auto`}
srcSet={srcset}
sizes={sizes}
alt={alt}
className={className}
loading="lazy"
decoding="async"
/>
);
}
// Usage
<ResponsiveImage
imageId="abc123"
alt="Product image"
sizes="(max-width: 768px) 100vw, 50vw"
/>
Use R2 + Image Resizing for on-the-fly transforms:
// workers/image-transform.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// Parse transform options from URL
// Format: /transform/w=300,h=200,fit=cover/{imagePath}
const match = path.match(/^\/transform\/([^/]+)\/(.+)$/);
if (!match) {
return new Response('Not found', { status: 404 });
}
const [, optionsStr, imagePath] = match;
const options = parseTransformOptions(optionsStr);
// Fetch original from R2
const object = await env.R2_IMAGES.get(imagePath);
if (!object) {
return new Response('Image not found', { status: 404 });
}
// Apply transformations via cf.image
return fetch(request.url, {
cf: {
image: {
width: options.width,
height: options.height,
fit: options.fit || 'cover',
format: 'auto', // Auto-detect WebP/AVIF support
quality: options.quality || 85,
},
},
});
},
};
function parseTransformOptions(str: string): Record<string, any> {
const options: Record<string, any> = {};
str.split(',').forEach(part => {
const [key, value] = part.split('=');
options[key] = isNaN(Number(value)) ? value : Number(value);
});
return options;
}
graph LR
subgraph "Broadcaster"
OBS[OBS/Encoder]
end
subgraph "Cloudflare Stream"
RTMPS[RTMPS Ingest]
Encode[Real-time Encoding]
HLS[HLS/DASH Output]
end
subgraph "Viewers"
P1[Player 1]
P2[Player 2]
PN[Player N]
end
OBS -->|RTMPS| RTMPS --> Encode --> HLS
HLS --> P1
HLS --> P2
HLS --> PN
// Create live input
async function createLiveInput(env: Env, name: string): Promise<LiveInput> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/live_inputs`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
meta: { name },
recording: {
mode: 'automatic', // or 'off'
timeoutSeconds: 0, // No timeout
requireSignedURLs: true,
},
}),
}
);
const result = await response.json();
return {
uid: result.result.uid,
rtmps: result.result.rtmps,
srt: result.result.srt,
webRTC: result.result.webRTC,
};
}
// RTMPS URL format:
// rtmps://live.cloudflare.com:443/live/{streamKey}
| Use Case | Signed URL | Expiration | Notes |
|---|---|---|---|
| Paid content | Required | 1-4 hours | Short expiry for VOD |
| User uploads | Required | 30 minutes | For upload URL only |
| Live streams | Recommended | Per-session | Regenerate on refresh |
| Public content | Optional | N/A | For analytics tracking |
const accessRules = [
// Allow from specific countries
{
type: 'ip.geoip.country',
action: 'allow',
country: ['US', 'CA', 'GB'],
},
// Block specific IPs (abuse prevention)
{
type: 'ip.src',
action: 'block',
value: ['192.168.1.1'],
},
// Require referrer (hotlink protection)
{
type: 'any',
action: 'allow',
// Combined with allowedOrigins in upload config
},
];
{
"name": "media-platform",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"d1_databases": [
{ "binding": "DB", "database_name": "media-db", "database_id": "..." }
],
"r2_buckets": [
{ "binding": "R2_IMAGES", "bucket_name": "images" },
{ "binding": "R2_VIDEOS", "bucket_name": "videos-raw" }
],
"vars": {
"CF_ACCOUNT_ID": "your-account-id",
"CF_IMAGES_HASH": "your-images-hash",
"STREAM_SIGNING_KEY_ID": "key-id"
}
}
# Media Delivery Report
## Stream Statistics
| Metric | Value | Cost Estimate |
|--------|-------|---------------|
| Videos stored | 1,500 min | $7.50/month |
| Minutes viewed (30d) | 50,000 min | $50/month |
| Unique videos | 45 | - |
## Images Statistics
| Metric | Value | Cost Estimate |
|--------|-------|---------------|
| Images stored | 25,000 | $1.25/month |
| Unique transforms | 75,000 | $37.50/month |
| Images delivered | 2M | $20/month |
## Optimization Opportunities
| Issue | Current | Optimized | Savings |
|-------|---------|-----------|---------|
| Unused transforms | 500 variants | 50 variants | ~$22/mo |
| Oversized images | avg 2000px | max 1920px | ~$5/mo |
format=auto for best compressionloading="lazy" to images below fold<link rel="preload"> for hero imagesthumbnailTimestampPct for better previewsThis skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.