mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-03-21 18:11:27 -07:00
update skills
This commit is contained in:
114
.agents/skills/cloudflare-deploy/references/stream/README.md
Normal file
114
.agents/skills/cloudflare-deploy/references/stream/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Cloudflare Stream
|
||||
|
||||
Serverless live and on-demand video streaming platform with one API.
|
||||
|
||||
## Overview
|
||||
|
||||
Cloudflare Stream provides video upload, storage, encoding, and delivery without managing infrastructure. Runs on Cloudflare's global network.
|
||||
|
||||
### Key Features
|
||||
- **On-demand video**: Upload, encode, store, deliver
|
||||
- **Live streaming**: RTMPS/SRT ingestion with ABR
|
||||
- **Direct creator uploads**: End users upload without API keys
|
||||
- **Signed URLs**: Token-based access control
|
||||
- **Analytics**: Server-side metrics via GraphQL
|
||||
- **Webhooks**: Processing notifications
|
||||
- **Captions**: Upload or AI-generate subtitles
|
||||
- **Watermarks**: Apply branding to videos
|
||||
- **Downloads**: Enable MP4 offline viewing
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Video Upload Methods
|
||||
1. **API Upload (TUS protocol)**: Direct server upload
|
||||
2. **Upload from URL**: Import from external source
|
||||
3. **Direct Creator Uploads**: User-generated content (recommended)
|
||||
|
||||
### Playback Options
|
||||
1. **Stream Player (iframe)**: Built-in, optimized player
|
||||
2. **Custom Player (HLS/DASH)**: Video.js, HLS.js integration
|
||||
3. **Thumbnails**: Static or animated previews
|
||||
|
||||
### Access Control
|
||||
- **Public**: No restrictions
|
||||
- **requireSignedURLs**: Token-based access
|
||||
- **allowedOrigins**: Domain restrictions
|
||||
- **Access Rules**: Geo/IP restrictions in tokens
|
||||
|
||||
### Live Streaming
|
||||
- RTMPS/SRT ingest from OBS, FFmpeg
|
||||
- Automatic recording to on-demand
|
||||
- Simulcast to YouTube, Twitch, etc.
|
||||
- WebRTC support for browser streaming
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Upload video via API**
|
||||
```bash
|
||||
curl -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/copy" \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com/video.mp4"}'
|
||||
```
|
||||
|
||||
**Embed player**
|
||||
```html
|
||||
<iframe
|
||||
src="https://customer-<CODE>.cloudflarestream.com/<VIDEO_ID>/iframe"
|
||||
style="border: none;"
|
||||
height="720" width="1280"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
**Create live input**
|
||||
```bash
|
||||
curl -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/live_inputs" \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"recording": {"mode": "automatic"}}'
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
- Max file size: 30 GB
|
||||
- Max frame rate: 60 fps (recommended)
|
||||
- Supported formats: MP4, MKV, MOV, AVI, FLV, MPEG-2 TS/PS, MXF, LXF, GXF, 3GP, WebM, MPG, QuickTime
|
||||
|
||||
## Pricing
|
||||
|
||||
- $5/1000 min stored
|
||||
- $1/1000 min delivered
|
||||
|
||||
## Resources
|
||||
|
||||
- Dashboard: https://dash.cloudflare.com/?to=/:account/stream
|
||||
- API Docs: https://developers.cloudflare.com/api/resources/stream/
|
||||
- Stream Docs: https://developers.cloudflare.com/stream/
|
||||
|
||||
## Reading Order
|
||||
|
||||
| Order | File | Purpose | When to Use |
|
||||
|-------|------|---------|-------------|
|
||||
| 1 | [configuration.md](./configuration.md) | Setup SDKs, env vars, signing keys | Starting new project |
|
||||
| 2 | [api.md](./api.md) | On-demand video APIs | Implementing uploads/playback |
|
||||
| 3 | [api-live.md](./api-live.md) | Live streaming APIs | Building live streaming |
|
||||
| 4 | [patterns.md](./patterns.md) | Full-stack flows, TUS, JWT signing | Implementing workflows |
|
||||
| 5 | [gotchas.md](./gotchas.md) | Errors, limits, troubleshooting | Debugging issues |
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [configuration.md](./configuration.md) - Setup, environment variables, wrangler config
|
||||
- [api.md](./api.md) - On-demand video upload, playback, management APIs
|
||||
- [api-live.md](./api-live.md) - Live streaming (RTMPS/SRT/WebRTC), simulcast
|
||||
- [patterns.md](./patterns.md) - Full-stack flows, state management, best practices
|
||||
- [gotchas.md](./gotchas.md) - Error codes, troubleshooting, limits
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Deploy Stream APIs in Workers
|
||||
- [pages](../pages/) - Integrate Stream with Pages
|
||||
- [workers-ai](../workers-ai/) - AI-generate captions
|
||||
195
.agents/skills/cloudflare-deploy/references/stream/api-live.md
Normal file
195
.agents/skills/cloudflare-deploy/references/stream/api-live.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Stream Live Streaming API
|
||||
|
||||
Live input creation, status checking, simulcast, and WebRTC streaming.
|
||||
|
||||
## Create Live Input
|
||||
|
||||
### Using Cloudflare SDK
|
||||
|
||||
```typescript
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
const client = new Cloudflare({ apiToken: env.CF_API_TOKEN });
|
||||
|
||||
const liveInput = await client.stream.liveInputs.create({
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
recording: { mode: 'automatic', timeoutSeconds: 30 },
|
||||
deleteRecordingAfterDays: 30
|
||||
});
|
||||
|
||||
// Returns: { uid, rtmps, srt, webRTC }
|
||||
```
|
||||
|
||||
### Raw fetch API
|
||||
|
||||
```typescript
|
||||
async function createLiveInput(accountId: string, apiToken: string) {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/live_inputs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recording: { mode: 'automatic', timeoutSeconds: 30 },
|
||||
deleteRecordingAfterDays: 30
|
||||
})
|
||||
}
|
||||
);
|
||||
const { result } = await response.json();
|
||||
return {
|
||||
uid: result.uid,
|
||||
rtmps: { url: result.rtmps.url, streamKey: result.rtmps.streamKey },
|
||||
srt: { url: result.srt.url, streamId: result.srt.streamId, passphrase: result.srt.passphrase },
|
||||
webRTC: result.webRTC
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Check Live Status
|
||||
|
||||
```typescript
|
||||
async function getLiveStatus(accountId: string, liveInputId: string, apiToken: string) {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/live_inputs/${liveInputId}`,
|
||||
{ headers: { 'Authorization': `Bearer ${apiToken}` } }
|
||||
);
|
||||
const { result } = await response.json();
|
||||
return {
|
||||
isLive: result.status?.current?.state === 'connected',
|
||||
recording: result.recording,
|
||||
status: result.status
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Simulcast (Live Outputs)
|
||||
|
||||
### Create Output
|
||||
|
||||
```typescript
|
||||
async function createLiveOutput(
|
||||
accountId: string, liveInputId: string, apiToken: string,
|
||||
outputUrl: string, streamKey: string
|
||||
) {
|
||||
return fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/live_inputs/${liveInputId}/outputs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: `${outputUrl}/${streamKey}`,
|
||||
enabled: true,
|
||||
streamKey // For platforms like YouTube, Twitch
|
||||
})
|
||||
}
|
||||
).then(r => r.json());
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Simulcast to YouTube + Twitch
|
||||
|
||||
```typescript
|
||||
const liveInput = await createLiveInput(accountId, apiToken);
|
||||
|
||||
// Add YouTube output
|
||||
await createLiveOutput(
|
||||
accountId, liveInput.uid, apiToken,
|
||||
'rtmp://a.rtmp.youtube.com/live2',
|
||||
'your-youtube-stream-key'
|
||||
);
|
||||
|
||||
// Add Twitch output
|
||||
await createLiveOutput(
|
||||
accountId, liveInput.uid, apiToken,
|
||||
'rtmp://live.twitch.tv/app',
|
||||
'your-twitch-stream-key'
|
||||
);
|
||||
```
|
||||
|
||||
## WebRTC Streaming (WHIP/WHEP)
|
||||
|
||||
### Browser to Stream (WHIP)
|
||||
|
||||
```typescript
|
||||
async function startWebRTCBroadcast(liveInputId: string) {
|
||||
const pc = new RTCPeerConnection();
|
||||
|
||||
// Add local media tracks
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
// Create offer
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// Send to Stream via WHIP
|
||||
const response = await fetch(
|
||||
`https://customer-<CODE>.cloudflarestream.com/${liveInputId}/webRTC/publish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: offer.sdp
|
||||
}
|
||||
);
|
||||
|
||||
const answer = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||
}
|
||||
```
|
||||
|
||||
### Stream to Browser (WHEP)
|
||||
|
||||
```typescript
|
||||
async function playWebRTCStream(videoId: string) {
|
||||
const pc = new RTCPeerConnection();
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const response = await fetch(
|
||||
`https://customer-<CODE>.cloudflarestream.com/${videoId}/webRTC/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: offer.sdp
|
||||
}
|
||||
);
|
||||
|
||||
const answer = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||
|
||||
return pc;
|
||||
}
|
||||
```
|
||||
|
||||
## Recording Settings
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `automatic` | Record all live streams |
|
||||
| `off` | No recording |
|
||||
| `timeoutSeconds` | Stop recording after N seconds of inactivity |
|
||||
|
||||
```typescript
|
||||
const recordingConfig = {
|
||||
mode: 'automatic',
|
||||
timeoutSeconds: 30, // Auto-stop 30s after stream ends
|
||||
requireSignedURLs: true, // Require token for VOD playback
|
||||
allowedOrigins: ['https://yourdomain.com']
|
||||
};
|
||||
```
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [README.md](./README.md) - Overview and quick start
|
||||
- [api.md](./api.md) - On-demand video APIs
|
||||
- [configuration.md](./configuration.md) - Setup and config
|
||||
- [patterns.md](./patterns.md) - Full-stack flows, best practices
|
||||
- [gotchas.md](./gotchas.md) - Error codes, troubleshooting
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Deploy live APIs in Workers
|
||||
199
.agents/skills/cloudflare-deploy/references/stream/api.md
Normal file
199
.agents/skills/cloudflare-deploy/references/stream/api.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Stream API Reference
|
||||
|
||||
Upload, playback, live streaming, and management APIs.
|
||||
|
||||
## Upload APIs
|
||||
|
||||
### Direct Creator Upload (Recommended)
|
||||
|
||||
**Backend: Create upload URL (SDK)**
|
||||
```typescript
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
const client = new Cloudflare({ apiToken: env.CF_API_TOKEN });
|
||||
|
||||
const uploadData = await client.stream.directUpload.create({
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
maxDurationSeconds: 3600,
|
||||
requireSignedURLs: true,
|
||||
meta: { creator: 'user-123' }
|
||||
});
|
||||
// Returns: { uploadURL: string, uid: string }
|
||||
```
|
||||
|
||||
**Frontend: Upload file**
|
||||
```typescript
|
||||
async function uploadVideo(file: File, uploadURL: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetch(uploadURL, { method: 'POST', body: formData }).then(r => r.json());
|
||||
}
|
||||
```
|
||||
|
||||
### Upload from URL
|
||||
|
||||
```typescript
|
||||
const video = await client.stream.copy.create({
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
url: 'https://example.com/video.mp4',
|
||||
meta: { name: 'My Video' },
|
||||
requireSignedURLs: false
|
||||
});
|
||||
```
|
||||
|
||||
## Playback APIs
|
||||
|
||||
### Embed Player (iframe)
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://customer-<CODE>.cloudflarestream.com/<VIDEO_ID>/iframe?autoplay=true&muted=true"
|
||||
style="border: none;" height="720" width="1280"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
### HLS/DASH Manifest URLs
|
||||
|
||||
```typescript
|
||||
// HLS
|
||||
const hlsUrl = `https://customer-<CODE>.cloudflarestream.com/${videoId}/manifest/video.m3u8`;
|
||||
|
||||
// DASH
|
||||
const dashUrl = `https://customer-<CODE>.cloudflarestream.com/${videoId}/manifest/video.mpd`;
|
||||
```
|
||||
|
||||
### Thumbnails
|
||||
|
||||
```typescript
|
||||
// At specific time (seconds)
|
||||
const thumb = `https://customer-<CODE>.cloudflarestream.com/${videoId}/thumbnails/thumbnail.jpg?time=10s`;
|
||||
|
||||
// By percentage
|
||||
const thumbPct = `https://customer-<CODE>.cloudflarestream.com/${videoId}/thumbnails/thumbnail.jpg?time=50%`;
|
||||
|
||||
// Animated GIF
|
||||
const gif = `https://customer-<CODE>.cloudflarestream.com/${videoId}/thumbnails/thumbnail.gif`;
|
||||
```
|
||||
|
||||
## Signed URLs
|
||||
|
||||
```typescript
|
||||
// Low volume (<1k/day): Use API
|
||||
async function getSignedToken(accountId: string, videoId: string, apiToken: string) {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/${videoId}/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
accessRules: [{ type: 'ip.geoip.country', action: 'allow', country: ['US'] }]
|
||||
})
|
||||
}
|
||||
);
|
||||
return (await response.json()).result.token;
|
||||
}
|
||||
|
||||
// High volume: Self-sign with RS256 JWT (see "Self-Sign JWT" in patterns.md)
|
||||
```
|
||||
|
||||
## Captions & Clips
|
||||
|
||||
### Upload Captions
|
||||
|
||||
```typescript
|
||||
async function uploadCaption(
|
||||
accountId: string, videoId: string, apiToken: string,
|
||||
language: string, captionFile: File
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', captionFile);
|
||||
return fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/${videoId}/captions/${language}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}` },
|
||||
body: formData
|
||||
}
|
||||
).then(r => r.json());
|
||||
}
|
||||
```
|
||||
|
||||
### Generate AI Captions
|
||||
|
||||
```typescript
|
||||
// TODO: Requires Workers AI integration - see workers-ai reference
|
||||
async function generateAICaptions(accountId: string, videoId: string, apiToken: string) {
|
||||
return fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/${videoId}/captions/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: 'en' })
|
||||
}
|
||||
).then(r => r.json());
|
||||
}
|
||||
```
|
||||
|
||||
### Clip Video
|
||||
|
||||
```typescript
|
||||
async function clipVideo(
|
||||
accountId: string, videoId: string, apiToken: string,
|
||||
startTime: number, endTime: number
|
||||
) {
|
||||
return fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream/clip`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clippedFromVideoUID: videoId,
|
||||
startTimeSeconds: startTime,
|
||||
endTimeSeconds: endTime
|
||||
})
|
||||
}
|
||||
).then(r => r.json());
|
||||
}
|
||||
```
|
||||
|
||||
## Video Management
|
||||
|
||||
```typescript
|
||||
// List videos
|
||||
const videos = await client.stream.videos.list({
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
search: 'keyword' // optional
|
||||
});
|
||||
|
||||
// Get video details
|
||||
const video = await client.stream.videos.get(videoId, {
|
||||
account_id: env.CF_ACCOUNT_ID
|
||||
});
|
||||
|
||||
// Update video
|
||||
await client.stream.videos.update(videoId, {
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
meta: { title: 'New Title' },
|
||||
requireSignedURLs: true
|
||||
});
|
||||
|
||||
// Delete video
|
||||
await client.stream.videos.delete(videoId, {
|
||||
account_id: env.CF_ACCOUNT_ID
|
||||
});
|
||||
```
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [README.md](./README.md) - Overview and quick start
|
||||
- [configuration.md](./configuration.md) - Setup and config
|
||||
- [api-live.md](./api-live.md) - Live streaming APIs (RTMPS/SRT/WebRTC)
|
||||
- [patterns.md](./patterns.md) - Full-stack flows, best practices
|
||||
- [gotchas.md](./gotchas.md) - Error codes, troubleshooting
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Deploy Stream APIs in Workers
|
||||
@@ -0,0 +1,141 @@
|
||||
# Stream Configuration
|
||||
|
||||
Setup, environment variables, and wrangler configuration.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Official Cloudflare SDK (Node.js, Workers, Pages)
|
||||
npm install cloudflare
|
||||
|
||||
# React component library
|
||||
npm install @cloudflare/stream-react
|
||||
|
||||
# TUS resumable uploads (large files)
|
||||
npm install tus-js-client
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
CF_ACCOUNT_ID=your-account-id
|
||||
CF_API_TOKEN=your-api-token
|
||||
|
||||
# For signed URLs (high volume)
|
||||
STREAM_KEY_ID=your-key-id
|
||||
STREAM_JWK=base64-encoded-jwk
|
||||
|
||||
# For webhooks
|
||||
WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Customer subdomain (from dashboard)
|
||||
STREAM_CUSTOMER_CODE=your-customer-code
|
||||
```
|
||||
|
||||
## Wrangler Configuration
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "stream-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-01-01", // Use current date for new projects
|
||||
"vars": {
|
||||
"CF_ACCOUNT_ID": "your-account-id"
|
||||
}
|
||||
// Store secrets: wrangler secret put CF_API_TOKEN
|
||||
// wrangler secret put STREAM_KEY_ID
|
||||
// wrangler secret put STREAM_JWK
|
||||
// wrangler secret put WEBHOOK_SECRET
|
||||
}
|
||||
```
|
||||
|
||||
## Signing Keys (High Volume)
|
||||
|
||||
Create once for self-signing tokens (thousands of daily users).
|
||||
|
||||
**Create key**
|
||||
```bash
|
||||
curl -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/keys" \
|
||||
-H "Authorization: Bearer <API_TOKEN>"
|
||||
|
||||
# Save `id` and `jwk` (base64) from response
|
||||
```
|
||||
|
||||
**Store in secrets**
|
||||
```bash
|
||||
wrangler secret put STREAM_KEY_ID
|
||||
wrangler secret put STREAM_JWK
|
||||
```
|
||||
|
||||
## Webhooks
|
||||
|
||||
**Setup webhook URL**
|
||||
```bash
|
||||
curl -X PUT \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/webhook" \
|
||||
-H "Authorization: Bearer <API_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notificationUrl": "https://your-worker.workers.dev/webhook"}'
|
||||
|
||||
# Save the returned `secret` for signature verification
|
||||
```
|
||||
|
||||
**Store secret**
|
||||
```bash
|
||||
wrangler secret put WEBHOOK_SECRET
|
||||
```
|
||||
|
||||
## Direct Upload / Live / Watermark Config
|
||||
|
||||
```typescript
|
||||
// Direct upload
|
||||
const uploadConfig = {
|
||||
maxDurationSeconds: 3600,
|
||||
expiry: new Date(Date.now() + 3600000).toISOString(),
|
||||
requireSignedURLs: true,
|
||||
allowedOrigins: ['https://yourdomain.com'],
|
||||
meta: { creator: 'user-123' }
|
||||
};
|
||||
|
||||
// Live input
|
||||
const liveConfig = {
|
||||
recording: { mode: 'automatic', timeoutSeconds: 30 },
|
||||
deleteRecordingAfterDays: 30
|
||||
};
|
||||
|
||||
// Watermark
|
||||
const watermark = {
|
||||
name: 'Logo', opacity: 0.7, padding: 20,
|
||||
position: 'lowerRight', scale: 0.15
|
||||
};
|
||||
```
|
||||
|
||||
## Access Rules & Player Config
|
||||
|
||||
```typescript
|
||||
// Access rules: allow US/CA, block CN/RU, or IP allowlist
|
||||
const geoRestrict = [
|
||||
{ type: 'ip.geoip.country', action: 'allow', country: ['US', 'CA'] },
|
||||
{ type: 'any', action: 'block' }
|
||||
];
|
||||
|
||||
// Player params for iframe
|
||||
const playerParams = new URLSearchParams({
|
||||
autoplay: 'true', muted: 'true', preload: 'auto', defaultTextTrack: 'en'
|
||||
});
|
||||
```
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [README.md](./README.md) - Overview and quick start
|
||||
- [api.md](./api.md) - On-demand video APIs
|
||||
- [api-live.md](./api-live.md) - Live streaming APIs
|
||||
- [patterns.md](./patterns.md) - Full-stack flows, best practices
|
||||
- [gotchas.md](./gotchas.md) - Error codes, troubleshooting
|
||||
|
||||
## See Also
|
||||
|
||||
- [wrangler](../wrangler/) - Wrangler CLI and configuration
|
||||
- [workers](../workers/) - Deploy Stream APIs in Workers
|
||||
130
.agents/skills/cloudflare-deploy/references/stream/gotchas.md
Normal file
130
.agents/skills/cloudflare-deploy/references/stream/gotchas.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Stream Gotchas
|
||||
|
||||
## Common Errors
|
||||
|
||||
### "ERR_NON_VIDEO"
|
||||
|
||||
**Cause:** Uploaded file is not a valid video format
|
||||
**Solution:** Ensure file is in supported format (MP4, MKV, MOV, AVI, FLV, MPEG-2 TS/PS, MXF, LXF, GXF, 3GP, WebM, MPG, QuickTime)
|
||||
|
||||
### "ERR_DURATION_EXCEED_CONSTRAINT"
|
||||
|
||||
**Cause:** Video duration exceeds `maxDurationSeconds` constraint
|
||||
**Solution:** Increase `maxDurationSeconds` in direct upload config or trim video before upload
|
||||
|
||||
### "ERR_FETCH_ORIGIN_ERROR"
|
||||
|
||||
**Cause:** Failed to download video from URL (upload from URL)
|
||||
**Solution:** Ensure URL is publicly accessible, uses HTTPS, and video file is available
|
||||
|
||||
### "ERR_MALFORMED_VIDEO"
|
||||
|
||||
**Cause:** Video file is corrupted or improperly encoded
|
||||
**Solution:** Re-encode video using FFmpeg or check source file integrity
|
||||
|
||||
### "ERR_DURATION_TOO_SHORT"
|
||||
|
||||
**Cause:** Video must be at least 0.1 seconds long
|
||||
**Solution:** Ensure video has valid duration (not a single frame)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Video stuck in "inprogress" state
|
||||
- **Cause**: Processing large/complex video
|
||||
- **Solution**: Wait up to 5 minutes for processing; use webhooks instead of polling
|
||||
|
||||
### Signed URL returns 403
|
||||
- **Cause**: Token expired or invalid signature
|
||||
- **Solution**: Check expiration timestamp, verify JWK is correct, ensure clock sync
|
||||
|
||||
### Live stream not connecting
|
||||
- **Cause**: Invalid RTMPS URL or stream key
|
||||
- **Solution**: Use exact URL/key from API, ensure firewall allows outbound 443
|
||||
|
||||
### Webhook signature verification fails
|
||||
- **Cause**: Incorrect secret or timestamp window
|
||||
- **Solution**: Use exact secret from webhook setup, allow 5-minute timestamp drift
|
||||
|
||||
### Video uploads but isn't visible
|
||||
- **Cause**: `requireSignedURLs` enabled without providing token
|
||||
- **Solution**: Generate signed token or set `requireSignedURLs: false` for public videos
|
||||
|
||||
### Player shows infinite loading
|
||||
- **Cause**: CORS issue with allowedOrigins
|
||||
- **Solution**: Add your domain to `allowedOrigins` array
|
||||
|
||||
## Limits
|
||||
|
||||
| Resource | Limit |
|
||||
|----------|-------|
|
||||
| Max file size | 30 GB |
|
||||
| Max frame rate | 60 fps (recommended) |
|
||||
| Max duration per direct upload | Configurable via `maxDurationSeconds` |
|
||||
| Token generation (API endpoint) | 1,000/day recommended (use signing keys for higher) |
|
||||
| Live input outputs (simulcast) | 5 per live input |
|
||||
| Webhook retry attempts | 5 (exponential backoff) |
|
||||
| Webhook timeout | 30 seconds |
|
||||
| Caption file size | 5 MB |
|
||||
| Watermark image size | 2 MB |
|
||||
| Metadata keys per video | Unlimited |
|
||||
| Search results per page | Max 1,000 |
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Upload is slow
|
||||
- **Cause**: Large file size or network constraints
|
||||
- **Solution**: Use TUS resumable upload, compress video before upload, check bandwidth
|
||||
|
||||
### Playback buffering
|
||||
- **Cause**: Network congestion or low bandwidth
|
||||
- **Solution**: Use ABR (adaptive bitrate) with HLS/DASH, reduce max bitrate
|
||||
|
||||
### High processing time
|
||||
- **Cause**: Complex video codec, high resolution
|
||||
- **Solution**: Pre-encode with H.264 (most efficient), reduce resolution
|
||||
|
||||
## Type Safety
|
||||
|
||||
```typescript
|
||||
// Error response type
|
||||
interface StreamError {
|
||||
success: false;
|
||||
errors: Array<{
|
||||
code: number;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
async function uploadWithErrorHandling(url: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.errors[0]?.message || 'Upload failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Gotchas
|
||||
|
||||
1. **Never expose API token in frontend** - Use direct creator uploads
|
||||
2. **Always verify webhook signatures** - Prevent spoofed notifications
|
||||
3. **Set appropriate token expiration** - Short-lived for security
|
||||
4. **Use requireSignedURLs for private content** - Prevent unauthorized access
|
||||
5. **Whitelist allowedOrigins** - Prevent hotlinking/embedding on unauthorized sites
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [README.md](./README.md) - Overview and quick start
|
||||
- [configuration.md](./configuration.md) - Setup and config
|
||||
- [api.md](./api.md) - On-demand video APIs
|
||||
- [api-live.md](./api-live.md) - Live streaming APIs
|
||||
- [patterns.md](./patterns.md) - Full-stack flows, best practices
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Deploy Stream APIs securely
|
||||
184
.agents/skills/cloudflare-deploy/references/stream/patterns.md
Normal file
184
.agents/skills/cloudflare-deploy/references/stream/patterns.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Stream Patterns
|
||||
|
||||
Common workflows, full-stack flows, and best practices.
|
||||
|
||||
## React Stream Player
|
||||
|
||||
`npm install @cloudflare/stream-react`
|
||||
|
||||
```tsx
|
||||
import { Stream } from '@cloudflare/stream-react';
|
||||
|
||||
export function VideoPlayer({ videoId, token }: { videoId: string; token?: string }) {
|
||||
return <Stream controls src={token ? `${videoId}?token=${token}` : videoId} responsive />;
|
||||
}
|
||||
```
|
||||
|
||||
## Full-Stack Upload Flow
|
||||
|
||||
**Backend API (Workers/Pages)**
|
||||
```typescript
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const { videoName } = await request.json();
|
||||
const client = new Cloudflare({ apiToken: env.CF_API_TOKEN });
|
||||
const { uploadURL, uid } = await client.stream.directUpload.create({
|
||||
account_id: env.CF_ACCOUNT_ID,
|
||||
maxDurationSeconds: 3600,
|
||||
requireSignedURLs: true,
|
||||
meta: { name: videoName }
|
||||
});
|
||||
return Response.json({ uploadURL, uid });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Frontend component**
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
export function VideoUploader() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
const { uploadURL, uid } = await fetch('/api/upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ videoName: file.name })
|
||||
}).then(r => r.json());
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.onprogress = (e) => setProgress((e.loaded / e.total) * 100);
|
||||
xhr.onload = () => { setUploading(false); window.location.href = `/videos/${uid}`; };
|
||||
xhr.open('POST', uploadURL);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" accept="video/*" onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])} disabled={uploading} />
|
||||
{uploading && <progress value={progress} max={100} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## TUS Resumable Upload
|
||||
|
||||
For large files (>500MB). `npm install tus-js-client`
|
||||
|
||||
```typescript
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
async function uploadWithTUS(file: File, uploadURL: string, onProgress?: (pct: number) => void) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: uploadURL,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
chunkSize: 50 * 1024 * 1024,
|
||||
metadata: { filename: file.name, filetype: file.type },
|
||||
onError: reject,
|
||||
onProgress: (up, total) => onProgress?.((up / total) * 100),
|
||||
onSuccess: () => resolve(upload.url?.split('/').pop() || '')
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Video State Polling
|
||||
|
||||
```typescript
|
||||
async function waitForVideoReady(client: Cloudflare, accountId: string, videoId: string) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const video = await client.stream.videos.get(videoId, { account_id: accountId });
|
||||
if (video.readyToStream || video.status.state === 'error') return video;
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
throw new Error('Video processing timeout');
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Handler
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const signature = request.headers.get('Webhook-Signature');
|
||||
const body = await request.text();
|
||||
if (!signature || !await verifyWebhook(signature, body, env.WEBHOOK_SECRET)) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
const payload = JSON.parse(body);
|
||||
if (payload.readyToStream) console.log(`Video ${payload.uid} ready`);
|
||||
return new Response('OK');
|
||||
}
|
||||
};
|
||||
|
||||
async function verifyWebhook(sig: string, body: string, secret: string): Promise<boolean> {
|
||||
const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')));
|
||||
const timestamp = parseInt(parts.time || '0', 10);
|
||||
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const computed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`));
|
||||
const hex = Array.from(new Uint8Array(computed), b => b.toString(16).padStart(2, '0')).join('');
|
||||
return hex === parts.sig1;
|
||||
}
|
||||
```
|
||||
|
||||
## Self-Sign JWT (High Volume Tokens)
|
||||
|
||||
For >1k tokens/day. Prerequisites: Create signing key (see configuration.md).
|
||||
|
||||
```typescript
|
||||
async function selfSignToken(keyId: string, jwkBase64: string, videoId: string, expiresIn = 3600) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'jwk', JSON.parse(atob(jwkBase64)), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = btoa(JSON.stringify({ alg: 'RS256', kid: keyId })).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
const payload = btoa(JSON.stringify({ sub: videoId, kid: keyId, exp: now + expiresIn, nbf: now }))
|
||||
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
const message = `${header}.${payload}`;
|
||||
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(message));
|
||||
const b64Sig = btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return `${message}.${b64Sig}`;
|
||||
}
|
||||
|
||||
// With access rules (geo-restriction)
|
||||
const payloadWithRules = {
|
||||
sub: videoId, kid: keyId, exp: now + 3600, nbf: now,
|
||||
accessRules: [{ type: 'ip.geoip.country', action: 'allow', country: ['US'] }]
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use Direct Creator Uploads** - Avoid proxying through servers
|
||||
- **Enable requireSignedURLs** - Control private content access
|
||||
- **Self-sign tokens at scale** - Use signing keys for >1k/day
|
||||
- **Set allowedOrigins** - Prevent hotlinking
|
||||
- **Use webhooks over polling** - Efficient status updates
|
||||
- **Set maxDurationSeconds** - Prevent abuse
|
||||
- **Enable live recordings** - Auto VOD after stream
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [README.md](./README.md) - Overview and quick start
|
||||
- [configuration.md](./configuration.md) - Setup and config
|
||||
- [api.md](./api.md) - On-demand video APIs
|
||||
- [api-live.md](./api-live.md) - Live streaming APIs
|
||||
- [gotchas.md](./gotchas.md) - Error codes, troubleshooting
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Deploy Stream APIs in Workers
|
||||
- [pages](../pages/) - Integrate Stream with Pages
|
||||
Reference in New Issue
Block a user