OpenInspection supports two video backends for inspection walk-through clips. The default is R2 (free, zero marginal cost). Cloudflare Stream is an optional paid upgrade that adds adaptive bitrate delivery, automatic poster generation, and GPS/EXIF stripping on transcode.
Video clips are stored directly in the PHOTOS R2 bucket alongside field photos.
- Zero marginal cost — covered by Cloudflare R2 Free tier (10 GB storage, 1 M Class A ops/month).
- Clips are stored as-recorded. The worker buffers the clip (up to the 200 MB / 30 s cap) and writes it to R2 verbatim. Container-level GPS, EXIF, and other metadata are not stripped.
- Privacy checkbox. Because clips carry the original container metadata, the Media Studio
VideoCapturecomponent presents a privacy notice checkbox. The inspector must acknowledge it before the upload is allowed. The checkbox is absent on the Stream path because Stream re-transcodes on ingest, which drops container-level GPS/EXIF. - HTTP Range serving. Clips are served via
GET /api/inspections/:id/media/video/r2-object/:mediaIdwith full HTTP 206 Range support so the browser<video>element can seek without re-downloading.
{tenantId}/inspections/{inspectionId}/videos/{mediaId}.{ext} — clip
{tenantId}/inspections/{inspectionId}/videos/{mediaId}.poster.jpg — poster frame
Defined in server/lib/r2-keys.ts (r2Keys.inspectionVideo / r2Keys.inspectionVideoPoster).
Stream is the paid-tier video backend. It provides:
- Adaptive bitrate (HLS/DASH) via an embedded
<iframe>player. - Automatic poster frames at any
thumbnailTimestampPct. - GPS/EXIF stripping — Stream re-transcodes on ingest so container metadata is gone by the time the video is playable. The privacy checkbox is therefore not shown.
- 30-second duration cap enforced at the Stream edge (not in the worker).
Videos are uploaded directly from the browser to Cloudflare via a one-shot direct-creator-upload URL minted by the worker. Video bytes never transit the worker.
- Provision a Cloudflare Stream subscription on your Cloudflare account.
- Add the
STREAMbinding to yourwrangler.local.jsonc: - In the dashboard: Settings → Integrations → Video → toggle from R2 to Stream and enter your Stream customer subdomain (the
<id>.cloudflarestream.comprefix, e.g.customer-abc123). This value is stored intenant_configs.integrationConfig.streamCustomerSubdomain.
The resolver reads tenant_configs.videoMode. Default is 'r2'. When set to 'stream' but either the STREAM binding or the subdomain is absent, the worker returns 503 Service Unavailable (fail-closed — no silent fallback to R2).
In SaaS mode the video backend is plan-gated and not user-configurable:
| Tenant tier / status | Backend |
|---|---|
free or trial |
R2 |
pro or enterprise (non-trial) |
Stream |
The platform STREAM_CUSTOMER_SUBDOMAIN env var supplies the subdomain for all paid tenants. The toggle in Settings → Integrations → Video is hidden in SaaS mode.
server/services/video/resolve.ts exports resolveVideoBackend(c). It reads APP_MODE, then:
- SaaS: queries
tenants.tier+tenants.statusand applies the plan gate. - Self-host: queries
tenant_configs.videoMode(default'r2') andintegrationConfig.streamCustomerSubdomain.
Returns { backend: VideoBackend, provider: 'r2' | 'stream', streamSubdomain: string | null }.
The VideoBackend interface (server/services/video/types.ts) is satisfied by both R2VideoBackend and StreamVideoBackend:
createUpload(inspectionId) → { uploadURL, ref: VideoRef }
finalize(ref, posterRef?) → { poolId }
getDetails(ref) → { readyToStream, pctComplete?, durationSec? }
delete(ref) → void
PDF renders (via env.BROWSER.quickAction('pdf', ...)) cannot embed video. server/lib/report-video.ts (selectReportMedia) detects the PDF path (isPdf: true) and substitutes a Stream poster image + a QR code / link to the online player (/watch/:streamUid). R2 videos on the PDF path degrade to the poster JPEG served from R2.
| File | Covers |
|---|---|
tests/unit/report-style/resolve.spec.ts |
4-way resolution table, backend instance type, streamSubdomain propagation, fail-closed 503 |
tests/unit/storage/r2-backend.spec.ts |
Token mint/verify (tamper + expiry), type/size validation, R2 key shape, finalize idempotency |
tests/unit/media/media-video.spec.ts |
MediaVideoService (Stream) + StreamVideoBackend tenant guard and finalize |
tests/unit/reports/report-video.spec.ts |
selectReportMedia — image / video-player / video-poster branches |
tests/unit/media/session-context-video.spec.ts |
sessionContext video fields exposed to the frontend |
app/components/media-studio/VideoPlayer.test.tsx |
VideoPlayer — Stream iframe vs native <video>, and the three fail-closed paths (no subdomain, missing R2 ids, still transcoding) |
app/components/media-studio/VideoCapture.test.tsx |
VideoCapture — privacy notice present (R2) / absent (Stream), and that accepting it is what ENABLES the pick button |
app/**/*.test.tsx), not under
tests/. This table previously named files under tests/web/unit/ — a
directory that does not exist — so it claimed coverage for years that was never
written. If a row here names a file, open it before believing it.
The assertion worth knowing about in VideoCapture.test.tsx is not that the
checkbox renders: it is that pickDisabled keeps the upload button disabled
until the box is ticked. A checkbox that renders but gates nothing passes every
presence test while letting an R2 upload start with no acceptance recorded.