← Back to list

media-streaming
by littlebearapps
Claude Code plugin: Senior Cloudflare Systems Engineer capabilities for architecture, cost optimization, security, and implementation
⭐ 0🍴 0📅 Jan 25, 2026
SKILL.md
name: media-streaming description: 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.
Cloudflare Media & Streaming Skill
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.
Service Overview
Cloudflare Stream
| 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 |
Cloudflare Images
| 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 |
Cloudflare Stream Patterns
Pattern 1: Video Upload with Signed URL
// 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,
};
}
Pattern 2: Signed Video Playback URL
// 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;
}
Pattern 3: HLS.js Player Integration
<!-- 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>
Pattern 4: Stream Webhook Handler
// 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!');
}
}
Cloudflare Images Patterns
Pattern 1: Image Upload API
// 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],
};
}
Pattern 2: Image URL Transformations
// 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',
});
Pattern 3: Named Variants
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`;
Pattern 4: Responsive Images with srcset
// 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"
/>
Pattern 5: Image Transform via Worker
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;
}
Live Streaming Architecture
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
Live Input Configuration
// 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}
Security Best Practices
Signed URL Requirements
| 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 |
Access Control Rules
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
},
];
Wrangler Configuration
{
"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"
}
}
Cost Optimization
Stream
- Encode once, deliver many: Re-encode only when quality issues
- Set max duration: Prevent infinite uploads
- Use signed URLs: Prevent bandwidth abuse
- Monitor viewer minutes: Primary cost driver
Images
- Use format=auto: Let Cloudflare choose optimal format
- Cache transforms: Same transform = 1 unique transform charge
- Batch uploads: Reduce API calls
- Clean up unused: Delete orphaned images monthly
Output Format
# 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 |
Tips
- Auto-detect format: Always use
format=autofor best compression - Lazy loading: Add
loading="lazy"to images below fold - Preload critical: Use
<link rel="preload">for hero images - Stream analytics: Use webhooks to track engagement
- Thumbnail timing: Set
thumbnailTimestampPctfor better previews - Regional delivery: Stream uses Cloudflare's global network automatically
Score
Total Score
75/100
Based on repository quality metrics
✓SKILL.md
SKILL.mdファイルが含まれている
+20
✓LICENSE
ライセンスが設定されている
+10
✓説明文
100文字以上の説明がある
+10
○人気
GitHub Stars 100以上
0/15
✓最近の活動
1ヶ月以内に更新
+10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
✓タグ
1つ以上のタグが設定されている
+5
Reviews
💬
Reviews coming soon
