Claude-skill-registry-data media-streaming
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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry-data
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry-data "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/media-streaming" ~/.claude/skills/majiayu000-claude-skill-registry-data-media-streaming && rm -rf "$T"
manifest:
data/media-streaming/SKILL.mdsource content
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
for best compressionformat=auto - Lazy loading: Add
to images below foldloading="lazy" - Preload critical: Use
for hero images<link rel="preload"> - Stream analytics: Use webhooks to track engagement
- Thumbnail timing: Set
for better previewsthumbnailTimestampPct - Regional delivery: Stream uses Cloudflare's global network automatically