A single MP4 in `/public` is trivial to download. Adaptive HLS plus short-lived URLs and controlled playback raises the bar without DRM theater.
Pipeline: FFmpeg → HLS segments
Transcode source to multiple rungs (360p, 720p, 1080p), segment with FFmpeg, and ship a master `.m3u8` plus `.ts` or fMP4 chunks. Store segments on object storage (S3, R2) behind a CDN — not in your Nuxt public folder.
Example one-liner mindset: input MP4 → H.264 + AAC ladder → 6-second segments → `playlist.m3u8`. Run once on upload; serve many times.
- `-hls_time 6` — segment length; shorter = faster start, more requests
- `-hls_playlist_type vod` — video on demand (not live sliding window)
- Separate audio group if you add multiple languages later
Playback: hls.js or native Safari
In the browser, `hls.js` attaches to a `<video>` element for Chrome/Firefox; Safari plays HLS natively. Your page requests the manifest only after the user is authenticated — the API returns a signed URL valid for minutes.
What is a blob URL for video?
`URL.createObjectURL(blob)` gives the `<video>` element a temporary `blob:https://yoursite.com/...` source in memory. Useful when you fetch encrypted or same-origin chunks and assemble them in JS — the address bar never shows a direct `.mp4` CDN link. Revoke with `URL.revokeObjectURL` on teardown to avoid memory leaks.
- Blob URL ≠ security by itself — anyone with DevTools still sees network requests
- Combine with signed cookies on CDN, short TTL tokens on manifest + segments
- Disable `controlsList="nodownload"` and right-click only as light UX friction
Architecture checklist
- Upload → queue job → FFmpeg on worker → upload segments to private bucket
- Nitro route: `GET /api/lessons/:id/stream` checks session → returns signed m3u8 URL
- CDN signed URLs expire in 5–15 minutes; refresh before playback ends
- Optional: per-user watermark overlay in CSS/canvas for paid courses