feat: adaptive bitrate streaming (ABR) for HLS-style encrypted video

Add multi-quality variant support for video content:
   - New ABR types in pkg/smsg/types.go (ABRManifest, Variant, ABRPresets)
   - New pkg/smsg/abr.go with manifest read/write and bandwidth estimation
   - New cmd/mkdemo-abr CLI tool for creating ABR variant sets via ffmpeg
   - WASM parseABRManifest and selectVariant functions
   - Demo page "Adaptive Quality" tab with ABR player
   - RFC-001 Section 3.7 documenting ABR format and algorithm
This commit is contained in:
snider 2026-01-13 15:40:15 +00:00
parent 8486242fd8
commit 63b8a3ecb6
6 changed files with 1046 additions and 6 deletions

View file

@ -11,6 +11,7 @@
| Date | Status | Notes |
|------|--------|-------|
| 2026-01-13 | Proposed | **Adaptive Bitrate (ABR)**: HLS-style multi-quality streaming with encrypted variants. New Section 3.7. All Future Work items complete. |
| 2026-01-12 | Proposed | **Chunked streaming**: v3 now supports optional ChunkSize for independently decryptable chunks - enables seek, HTTP Range, and decrypt-while-downloading. |
| 2026-01-12 | Proposed | **v3 Streaming**: LTHN rolling keys with configurable cadence (daily/12h/6h/1h). CEK wrapping for zero-trust streaming. WASM v1.3.0 with decryptV3(). |
| 2026-01-10 | Proposed | Technical review passed. Fixed section numbering (7.x, 8.x, 9.x, 11.x). Updated WASM size to 5.9MB. Implementation verified complete for stated scope. |
@ -316,6 +317,95 @@ SMSG is content-agnostic. Any file can be an attachment:
Multiple attachments per SMSG are supported (e.g., album + cover art + PDF booklet).
### 3.7 Adaptive Bitrate Streaming (ABR)
For large video content, ABR enables automatic quality switching based on network conditions—like HLS/DASH but with ChaCha20-Poly1305 encryption.
**Architecture:**
```
ABR Manifest (manifest.json)
├── Title: "My Video"
├── Version: "abr-v1"
├── Variants: [1080p, 720p, 480p, 360p]
└── DefaultIdx: 1 (720p)
track-1080p.smsg ──┐
track-720p.smsg ──┼── Each is standard v3 chunked SMSG
track-480p.smsg ──┤ Same password decrypts ALL variants
track-360p.smsg ──┘
```
**ABR Manifest Format:**
```json
{
"version": "abr-v1",
"title": "Content Title",
"duration": 300,
"variants": [
{
"name": "360p",
"bandwidth": 500000,
"width": 640,
"height": 360,
"codecs": "avc1.640028,mp4a.40.2",
"url": "track-360p.smsg",
"chunkCount": 12,
"fileSize": 18750000
},
{
"name": "720p",
"bandwidth": 2500000,
"width": 1280,
"height": 720,
"codecs": "avc1.640028,mp4a.40.2",
"url": "track-720p.smsg",
"chunkCount": 48,
"fileSize": 93750000
}
],
"defaultIdx": 1
}
```
**Bandwidth Estimation Algorithm:**
1. Measure download time for each chunk
2. Calculate bits per second: `(bytes × 8 × 1000) / timeMs`
3. Average last 3 samples for stability
4. Apply 80% safety factor to prevent buffering
**Variant Selection:**
```
Selected = highest quality where (bandwidth × 0.8) >= variant.bandwidth
```
**Key Properties:**
- **Same password for all variants**: CEK unwrapped once, works everywhere
- **Chunk-boundary switching**: Clean cuts, no partial chunk issues
- **Independent variants**: No cross-file dependencies
- **CDN-friendly**: Each variant is a standard file, cacheable separately
**Creating ABR Content:**
```bash
# Use mkdemo-abr to create variant set from source video
go run ./cmd/mkdemo-abr input.mp4 output-dir/ [password]
# Output:
# output-dir/manifest.json (ABR manifest)
# output-dir/track-1080p.smsg (v3 chunked, 5 Mbps)
# output-dir/track-720p.smsg (v3 chunked, 2.5 Mbps)
# output-dir/track-480p.smsg (v3 chunked, 1 Mbps)
# output-dir/track-360p.smsg (v3 chunked, 500 Kbps)
```
**Standard Presets:**
| Name | Resolution | Bitrate | Use Case |
|------|------------|---------|----------|
| 1080p | 1920×1080 | 5 Mbps | High quality, fast connections |
| 720p | 1280×720 | 2.5 Mbps | Default, most connections |
| 480p | 854×480 | 1 Mbps | Mobile, medium connections |
| 360p | 640×360 | 500 Kbps | Slow connections, previews |
## 4. Demo Page Architecture
**Live Demo**: https://demo.dapp.fm
@ -628,6 +718,7 @@ Local playback Third-party hosting
- [x] **Configurable cadence** - daily/12h/6h/1h key rotation
- [x] **WASM v1.3.0** - `BorgSMSG.decryptV3()` for streaming
- [x] **Chunked streaming** - Independently decryptable chunks for seek/streaming
- [x] **Adaptive Bitrate (ABR)** - HLS-style multi-quality streaming with encrypted variants
### 8.2 Fixed Issues
- [x] ~~Double base64 encoding bug~~ - Fixed by using binary format
@ -635,7 +726,7 @@ Local playback Third-party hosting
- [x] ~~Key wrapping for streaming~~ - Implemented in v3 format
### 8.3 Future Work
- [ ] Multi-bitrate adaptive streaming (like HLS/DASH but encrypted)
- [x] Multi-bitrate adaptive streaming (see Section 3.7 ABR)
- [x] Payment integration examples (see `docs/payment-integration.md`)
- [x] IPFS distribution guide (see `docs/ipfs-distribution.md`)
- [x] Demo page "Streaming" tab for v3 showcase
@ -725,10 +816,11 @@ SMSG includes version and format fields for forward compatibility:
|---------|--------|----------|
| 1.0 | v1 | ChaCha20-Poly1305, JSON+base64 attachments |
| 1.0 | **v2** | Binary attachments, zstd compression (25% smaller, 3-10x faster) |
| 1.0 | **v3** | LTHN rolling keys, CEK wrapping, chunked streaming |
| 1.0 | **v3+ABR** | Multi-quality variants with adaptive bitrate switching |
| 2 (future) | - | Algorithm negotiation, multiple KDFs |
| 3 (future) | - | Streaming chunks, adaptive bitrate, key wrapping |
Decoders MUST reject versions they don't understand. Encoders SHOULD use v2 format for production (smaller, faster).
Decoders MUST reject versions they don't understand. Use v2 for download-to-own, v3 for streaming, v3+ABR for video.
### 11.2 Third-Party Implementations
@ -771,6 +863,8 @@ The player is embeddable:
- WASM Module: `pkg/wasm/stmf/`
- Native App: `cmd/dapp-fm-app/`
- Demo Creator Tool: `cmd/mkdemo/`
- ABR Creator Tool: `cmd/mkdemo-abr/`
- ABR Package: `pkg/smsg/abr.go`
## 13. License

226
cmd/mkdemo-abr/main.go Normal file
View file

@ -0,0 +1,226 @@
// mkdemo-abr creates an ABR (Adaptive Bitrate) demo set from a source video.
// It uses ffmpeg to transcode to multiple bitrates, then encrypts each as v3 chunked SMSG.
//
// Usage: mkdemo-abr <input-video> <output-dir> [password]
//
// Output:
//
// output-dir/manifest.json - ABR manifest listing all variants
// output-dir/track-1080p.smsg - 1080p variant (5 Mbps)
// output-dir/track-720p.smsg - 720p variant (2.5 Mbps)
// output-dir/track-480p.smsg - 480p variant (1 Mbps)
// output-dir/track-360p.smsg - 360p variant (500 Kbps)
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/smsg"
)
// Preset defines a quality level for transcoding
type Preset struct {
Name string
Width int
Height int
Bitrate string // For ffmpeg (e.g., "5M")
BPS int // Bits per second for manifest
}
// Default presets matching ABRPresets in types.go
var presets = []Preset{
{"1080p", 1920, 1080, "5M", 5000000},
{"720p", 1280, 720, "2.5M", 2500000},
{"480p", 854, 480, "1M", 1000000},
{"360p", 640, 360, "500K", 500000},
}
func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: mkdemo-abr <input-video> <output-dir> [password]")
fmt.Println()
fmt.Println("Creates ABR variant set from source video using ffmpeg.")
fmt.Println()
fmt.Println("Output:")
fmt.Println(" output-dir/manifest.json - ABR manifest")
fmt.Println(" output-dir/track-1080p.smsg - 1080p (5 Mbps)")
fmt.Println(" output-dir/track-720p.smsg - 720p (2.5 Mbps)")
fmt.Println(" output-dir/track-480p.smsg - 480p (1 Mbps)")
fmt.Println(" output-dir/track-360p.smsg - 360p (500 Kbps)")
os.Exit(1)
}
inputFile := os.Args[1]
outputDir := os.Args[2]
// Check ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
fmt.Println("Error: ffmpeg not found in PATH")
fmt.Println("Install ffmpeg: https://ffmpeg.org/download.html")
os.Exit(1)
}
// Generate or use provided password
var password string
if len(os.Args) > 3 {
password = os.Args[3]
} else {
passwordBytes := make([]byte, 24)
if _, err := rand.Read(passwordBytes); err != nil {
fmt.Printf("Failed to generate password: %v\n", err)
os.Exit(1)
}
password = base64.RawURLEncoding.EncodeToString(passwordBytes)
}
// Create output directory
if err := os.MkdirAll(outputDir, 0755); err != nil {
fmt.Printf("Failed to create output directory: %v\n", err)
os.Exit(1)
}
// Get title from input filename
title := filepath.Base(inputFile)
ext := filepath.Ext(title)
if ext != "" {
title = title[:len(title)-len(ext)]
}
// Create ABR manifest
manifest := smsg.NewABRManifest(title)
fmt.Printf("Creating ABR variants for: %s\n", inputFile)
fmt.Printf("Output directory: %s\n", outputDir)
fmt.Printf("Password: %s\n\n", password)
// Process each preset
for _, preset := range presets {
fmt.Printf("Processing %s (%dx%d @ %s)...\n", preset.Name, preset.Width, preset.Height, preset.Bitrate)
// Step 1: Transcode with ffmpeg
tempFile := filepath.Join(outputDir, fmt.Sprintf("temp-%s.mp4", preset.Name))
if err := transcode(inputFile, tempFile, preset); err != nil {
fmt.Printf(" Warning: Transcode failed for %s: %v\n", preset.Name, err)
fmt.Printf(" Skipping this variant...\n")
continue
}
// Step 2: Read transcoded file
content, err := os.ReadFile(tempFile)
if err != nil {
fmt.Printf(" Error reading transcoded file: %v\n", err)
os.Remove(tempFile)
continue
}
// Step 3: Create SMSG message
msg := smsg.NewMessage("dapp.fm ABR Demo")
msg.Subject = fmt.Sprintf("%s - %s", title, preset.Name)
msg.From = "dapp.fm"
msg.AddBinaryAttachment(
fmt.Sprintf("%s-%s.mp4", strings.ReplaceAll(title, " ", "_"), preset.Name),
content,
"video/mp4",
)
// Step 4: Create manifest for this variant
variantManifest := smsg.NewManifest(title)
variantManifest.LicenseType = "perpetual"
variantManifest.Format = "dapp.fm/abr-v1"
// Step 5: Encrypt with v3 chunked format
params := &smsg.StreamParams{
License: password,
ChunkSize: smsg.DefaultChunkSize, // 1MB chunks
}
encrypted, err := smsg.EncryptV3(msg, params, variantManifest)
if err != nil {
fmt.Printf(" Error encrypting: %v\n", err)
os.Remove(tempFile)
continue
}
// Step 6: Write SMSG file
smsgFile := filepath.Join(outputDir, fmt.Sprintf("track-%s.smsg", preset.Name))
if err := os.WriteFile(smsgFile, encrypted, 0644); err != nil {
fmt.Printf(" Error writing SMSG: %v\n", err)
os.Remove(tempFile)
continue
}
// Step 7: Get chunk count from header
header, err := smsg.GetV3Header(encrypted)
if err != nil {
fmt.Printf(" Warning: Could not read header: %v\n", err)
}
chunkCount := 0
if header != nil && header.Chunked != nil {
chunkCount = header.Chunked.TotalChunks
}
// Step 8: Add variant to manifest
variant := smsg.Variant{
Name: preset.Name,
Bandwidth: preset.BPS,
Width: preset.Width,
Height: preset.Height,
Codecs: "avc1.640028,mp4a.40.2",
URL: fmt.Sprintf("track-%s.smsg", preset.Name),
ChunkCount: chunkCount,
FileSize: int64(len(encrypted)),
}
manifest.AddVariant(variant)
// Clean up temp file
os.Remove(tempFile)
fmt.Printf(" Created: %s (%d bytes, %d chunks)\n", smsgFile, len(encrypted), chunkCount)
}
if len(manifest.Variants) == 0 {
fmt.Println("\nError: No variants created. Check ffmpeg output.")
os.Exit(1)
}
// Write ABR manifest
manifestPath := filepath.Join(outputDir, "manifest.json")
if err := smsg.WriteABRManifest(manifest, manifestPath); err != nil {
fmt.Printf("Failed to write manifest: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n✓ Created ABR manifest: %s\n", manifestPath)
fmt.Printf("✓ Variants: %d\n", len(manifest.Variants))
fmt.Printf("✓ Default: %s\n", manifest.Variants[manifest.DefaultIdx].Name)
fmt.Printf("\nMaster Password: %s\n", password)
fmt.Println("\nStore this password securely - it decrypts ALL variants!")
}
// transcode uses ffmpeg to transcode the input to the specified preset
func transcode(input, output string, preset Preset) error {
args := []string{
"-i", input,
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
preset.Width, preset.Height, preset.Width, preset.Height),
"-c:v", "libx264",
"-preset", "medium",
"-b:v", preset.Bitrate,
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-y", // Overwrite output
output,
}
cmd := exec.Command("ffmpeg", args...)
cmd.Stderr = os.Stderr // Show ffmpeg output for debugging
return cmd.Run()
}

View file

@ -1490,6 +1490,7 @@
<div class="tab-buttons">
<button class="tab-btn active" data-streaming-tab="chunked">📦 Chunked Decrypt</button>
<button class="tab-btn" data-streaming-tab="seek">⏩ Seek Demo</button>
<button class="tab-btn" data-streaming-tab="abr">📶 Adaptive Quality</button>
</div>
<div class="tab-content">
<!-- Chunked Decryption Tab -->
@ -1575,6 +1576,86 @@
</div>
</div>
</div>
<!-- ABR (Adaptive Bitrate) Tab -->
<div id="streaming-abr" class="tab-panel">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📶</span> Adaptive Bitrate Streaming
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
Automatic quality switching based on your network speed. Like HLS/DASH but with ChaCha20-Poly1305 encryption.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">ABR Manifest URL:</label>
<input type="text" id="abr-manifest-url" placeholder="./abr/manifest.json"
value="./abr/manifest.json"
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">License Token:</label>
<input type="text" id="abr-license" placeholder="Enter license token..."
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
<button id="abr-start-btn" class="demo-btn" style="flex: 1;">
Start ABR Stream
</button>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<label style="color: #888; font-size: 0.85rem;">Quality:</label>
<select id="abr-quality-select" style="padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid rgba(255,255,255,0.1);">
<option value="auto">Auto</option>
</select>
</div>
</div>
<!-- ABR Status -->
<div id="abr-status" style="display: none; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<span style="color: #888; font-size: 0.85rem;">Current Quality:</span>
<span id="abr-current-quality" style="color: #00ff94; font-weight: 600;">-</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<span style="color: #888; font-size: 0.85rem;">Estimated Bandwidth:</span>
<span id="abr-bandwidth" style="color: #8338ec; font-weight: 600;">-</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #888; font-size: 0.85rem;">Variant Switches:</span>
<span id="abr-switches" style="color: #ff006e; font-weight: 600;">0</span>
</div>
</div>
<!-- ABR Progress -->
<div id="abr-progress" style="display: none;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #888; font-size: 0.85rem;">Download Progress</span>
<span id="abr-progress-text" style="color: #00ff94; font-size: 0.85rem;">0%</span>
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 8px; height: 12px; overflow: hidden; margin-bottom: 1rem;">
<div id="abr-progress-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
</div>
<!-- ABR Player -->
<div id="abr-player" style="display: none; margin-top: 1rem;">
<div id="abr-media-wrapper"></div>
</div>
<!-- ABR Info (placeholder until manifest loads) -->
<div id="abr-info" style="background: rgba(58, 134, 255, 0.1); border: 1px solid rgba(58, 134, 255, 0.3); border-radius: 12px; padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; color: #3a86ff; font-weight: 600; margin-bottom: 0.5rem;">
<span></span> How ABR Works
</div>
<div style="color: #888; font-size: 0.85rem; line-height: 1.6;">
<p>1. Manifest lists multiple quality variants (1080p, 720p, 480p, 360p)</p>
<p>2. Player measures your download speed</p>
<p>3. Automatically switches to best quality for your connection</p>
<p>4. Same password decrypts all variants</p>
</div>
</div>
</div>
</div>
</div>
@ -2899,7 +2980,7 @@
const tab = btn.dataset.streamingTab;
document.querySelectorAll('[data-streaming-tab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('#streaming-chunked, #streaming-seek').forEach(p => p.classList.remove('active'));
document.querySelectorAll('#streaming-chunked, #streaming-seek, #streaming-abr').forEach(p => p.classList.remove('active'));
document.getElementById('streaming-' + tab).classList.add('active');
});
});
@ -3231,6 +3312,285 @@
});
// WASM loads lazily on first interaction - no auto-init
// ========== ADAPTIVE BITRATE (ABR) STREAMING ==========
// ABR Controller - manages bandwidth estimation and variant selection
class ABRController {
constructor(manifest) {
this.manifest = manifest;
this.bandwidthSamples = [];
this.currentVariantIdx = manifest.defaultIdx;
this.maxSamples = 10;
this.switchCount = 0;
}
// Record a bandwidth sample from a download
recordSample(bytes, timeMs) {
if (timeMs <= 0) return;
const bps = (bytes * 8 * 1000) / timeMs;
this.bandwidthSamples.push(bps);
if (this.bandwidthSamples.length > this.maxSamples) {
this.bandwidthSamples.shift();
}
}
// Estimate current bandwidth using average of recent samples
estimateBandwidth() {
if (this.bandwidthSamples.length === 0) return 1000000; // 1 Mbps default
const count = Math.min(3, this.bandwidthSamples.length);
const recent = this.bandwidthSamples.slice(-count);
return recent.reduce((a, b) => a + b, 0) / count;
}
// Select best variant for current bandwidth
selectVariant() {
const bw = this.estimateBandwidth();
const safetyFactor = 0.8; // Use 80% of available bandwidth
let selected = 0;
for (let i = 0; i < this.manifest.variants.length; i++) {
if (this.manifest.variants[i].bandwidth <= bw * safetyFactor) {
selected = i;
}
}
return selected;
}
// Check if we should switch variants
shouldSwitch() {
const newVariant = this.selectVariant();
return newVariant !== this.currentVariantIdx;
}
// Switch to new variant
switchTo(idx) {
if (idx !== this.currentVariantIdx) {
this.currentVariantIdx = idx;
this.switchCount++;
}
}
// Format bandwidth for display
formatBandwidth(bps) {
if (bps >= 1000000) return (bps / 1000000).toFixed(1) + ' Mbps';
if (bps >= 1000) return (bps / 1000).toFixed(0) + ' Kbps';
return bps + ' bps';
}
}
// ABR state
let abrController = null;
let abrManifest = null;
let abrManualQuality = 'auto';
// Populate quality selector from manifest
function populateABRQualitySelector(manifest) {
const select = document.getElementById('abr-quality-select');
select.innerHTML = '<option value="auto">Auto</option>';
manifest.variants.forEach((v, i) => {
const bw = v.bandwidth >= 1000000
? (v.bandwidth / 1000000).toFixed(1) + ' Mbps'
: (v.bandwidth / 1000) + ' Kbps';
select.innerHTML += `<option value="${i}">${v.name} (${bw})</option>`;
});
}
// Update ABR status display
function updateABRStatus(abr, currentVariant) {
document.getElementById('abr-current-quality').textContent = currentVariant.name;
document.getElementById('abr-bandwidth').textContent = abr.formatBandwidth(abr.estimateBandwidth());
document.getElementById('abr-switches').textContent = abr.switchCount;
}
// Quality selector change
document.getElementById('abr-quality-select').addEventListener('change', (e) => {
abrManualQuality = e.target.value;
});
// Start ABR stream
document.getElementById('abr-start-btn').addEventListener('click', async () => {
const manifestUrl = document.getElementById('abr-manifest-url').value;
const license = document.getElementById('abr-license').value;
if (!manifestUrl) {
alert('Please enter a manifest URL');
return;
}
if (!license) {
alert('Please enter a license token');
return;
}
// Ensure WASM is ready
if (!wasmReady) {
document.getElementById('abr-start-btn').textContent = 'Loading WASM...';
document.getElementById('abr-start-btn').disabled = true;
await ensureWasm();
document.getElementById('abr-start-btn').textContent = 'Start ABR Stream';
document.getElementById('abr-start-btn').disabled = false;
}
const statusDiv = document.getElementById('abr-status');
const progressDiv = document.getElementById('abr-progress');
const playerDiv = document.getElementById('abr-player');
const infoDiv = document.getElementById('abr-info');
statusDiv.style.display = 'block';
progressDiv.style.display = 'block';
infoDiv.style.display = 'none';
try {
// 1. Fetch and parse ABR manifest
document.getElementById('abr-current-quality').textContent = 'Loading manifest...';
const manifestResponse = await fetch(manifestUrl);
if (!manifestResponse.ok) throw new Error('Manifest not found');
const manifestData = await manifestResponse.json();
// Parse manifest (can use WASM or direct)
abrManifest = manifestData;
if (abrManifest.version !== 'abr-v1') {
throw new Error('Invalid ABR manifest version');
}
// 2. Initialize ABR controller
abrController = new ABRController(abrManifest);
populateABRQualitySelector(abrManifest);
// 3. Select initial variant
let variantIdx = abrManualQuality === 'auto'
? abrController.selectVariant()
: parseInt(abrManualQuality);
abrController.currentVariantIdx = variantIdx;
let variant = abrManifest.variants[variantIdx];
updateABRStatus(abrController, variant);
// 4. Resolve variant URL relative to manifest
const baseUrl = manifestUrl.substring(0, manifestUrl.lastIndexOf('/') + 1);
let variantUrl = baseUrl + variant.url;
// 5. Fetch variant file
document.getElementById('abr-current-quality').textContent = variant.name + ' (downloading...)';
const startTime = performance.now();
const variantResponse = await fetch(variantUrl);
if (!variantResponse.ok) throw new Error('Variant file not found: ' + variant.url);
const variantBytes = new Uint8Array(await variantResponse.arrayBuffer());
const downloadTime = performance.now() - startTime;
// Record bandwidth sample
abrController.recordSample(variantBytes.length, downloadTime);
updateABRStatus(abrController, variant);
// 6. Parse v3 header
const header = await BorgSMSG.parseV3Header(variantBytes);
if (!header.chunked) throw new Error('Variant is not chunked');
// 7. Unwrap CEK
const cek = await BorgSMSG.unwrapCEKFromHeader(
header.wrappedKeys,
{ license: license, fingerprint: '' },
header.cadence
);
// 8. Decrypt chunks
const numChunks = header.chunked.totalChunks;
const decryptedChunks = [];
let decryptedBytes = 0;
let videoDataOffset = 0;
let mimeType = 'video/mp4';
for (let i = 0; i < numChunks; i++) {
const chunkInfo = header.chunked.index[i];
const chunkStart = header.payloadOffset + chunkInfo.offset;
const chunkEnd = chunkStart + chunkInfo.size;
const chunkBytes = variantBytes.subarray(chunkStart, chunkEnd);
const chunkStartTime = performance.now();
const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, cek);
const chunkTime = performance.now() - chunkStartTime;
// Record sample (simulate network download time for decryption measurement)
if (chunkTime > 0) {
abrController.recordSample(chunkBytes.length, chunkTime * 100); // Scale for simulation
}
decryptedChunks.push(decrypted);
decryptedBytes += decrypted.length;
// First chunk contains message metadata
if (i === 0) {
try {
const textDecoder = new TextDecoder();
let jsonEnd = 0;
for (let j = 0; j < Math.min(decrypted.length, 10000); j++) {
if (decrypted[j] === 0) { jsonEnd = j; break; }
}
if (jsonEnd > 0) {
const jsonStr = textDecoder.decode(decrypted.subarray(0, jsonEnd));
const msg = JSON.parse(jsonStr);
if (msg.attachments && msg.attachments.length > 0) {
mimeType = msg.attachments[0].mime || 'video/mp4';
}
videoDataOffset = jsonEnd + 1;
}
} catch (e) {
console.log('No embedded metadata');
}
}
// Update progress
const progress = (i + 1) / numChunks * 100;
document.getElementById('abr-progress-bar').style.width = progress + '%';
document.getElementById('abr-progress-text').textContent = Math.round(progress) + '%';
// Check for quality switch (in real ABR, would switch at this point)
if (abrManualQuality === 'auto' && abrController.shouldSwitch()) {
const newIdx = abrController.selectVariant();
abrController.switchTo(newIdx);
updateABRStatus(abrController, abrManifest.variants[newIdx]);
// Note: Real implementation would fetch new variant here
}
updateABRStatus(abrController, abrManifest.variants[abrController.currentVariantIdx]);
}
// 9. Combine and play
const totalSize = decryptedChunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decryptedChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const videoData = combined.subarray(videoDataOffset);
const blob = new Blob([videoData], { type: mimeType });
const url = URL.createObjectURL(blob);
// Show player
playerDiv.style.display = 'block';
const wrapper = document.getElementById('abr-media-wrapper');
wrapper.innerHTML = `
<div style="border-radius: 12px; overflow: hidden; background: #000;">
<video id="abr-video" style="width: 100%; max-height: 300px; display: block;" controls autoplay>
<source src="${url}" type="${mimeType}">
</video>
</div>
<div style="margin-top: 0.5rem; text-align: center; color: #888; font-size: 0.8rem;">
Playing: ${abrManifest.title} @ ${variant.name}
</div>
`;
document.getElementById('abr-current-quality').textContent = variant.name + ' (playing)';
document.getElementById('abr-progress-text').textContent = 'Complete';
} catch (err) {
console.error('ABR streaming failed:', err);
document.getElementById('abr-current-quality').textContent = 'Error: ' + err.message;
document.getElementById('abr-current-quality').style.color = '#ff5252';
}
});
</script>
</body>
</html>

214
pkg/smsg/abr.go Normal file
View file

@ -0,0 +1,214 @@
// Package smsg - Adaptive Bitrate Streaming (ABR) support
//
// ABR enables multi-bitrate streaming with automatic quality switching based on
// network conditions. Similar to HLS/DASH but with ChaCha20-Poly1305 encryption.
//
// Architecture:
// - Master manifest (.json) lists available quality variants
// - Each variant is a standard v3 chunked .smsg file
// - Same password decrypts all variants (CEK unwrapped once)
// - Player switches variants at chunk boundaries based on bandwidth
package smsg
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
const ABRVersion = "abr-v1"
// ABRSafetyFactor is the bandwidth multiplier for variant selection.
// Using 80% of available bandwidth prevents buffering on fluctuating networks.
const ABRSafetyFactor = 0.8
// NewABRManifest creates a new ABR manifest with the given title.
func NewABRManifest(title string) *ABRManifest {
return &ABRManifest{
Version: ABRVersion,
Title: title,
Variants: make([]Variant, 0),
DefaultIdx: 0,
}
}
// AddVariant adds a quality variant to the manifest.
// Variants are automatically sorted by bandwidth (ascending) after adding.
func (m *ABRManifest) AddVariant(v Variant) {
m.Variants = append(m.Variants, v)
// Sort by bandwidth ascending (lowest quality first)
sort.Slice(m.Variants, func(i, j int) bool {
return m.Variants[i].Bandwidth < m.Variants[j].Bandwidth
})
// Update default to 720p if available, otherwise middle variant
m.DefaultIdx = m.findDefaultVariant()
}
// findDefaultVariant finds the best default variant (prefers 720p).
func (m *ABRManifest) findDefaultVariant() int {
// Prefer 720p as default
for i, v := range m.Variants {
if v.Name == "720p" || v.Height == 720 {
return i
}
}
// Otherwise use middle variant
if len(m.Variants) > 0 {
return len(m.Variants) / 2
}
return 0
}
// SelectVariant selects the best variant for the given bandwidth (bits per second).
// Returns the index of the highest quality variant that fits within the bandwidth.
func (m *ABRManifest) SelectVariant(bandwidthBPS int) int {
safeBandwidth := float64(bandwidthBPS) * ABRSafetyFactor
// Find highest quality that fits
selected := 0
for i, v := range m.Variants {
if float64(v.Bandwidth) <= safeBandwidth {
selected = i
}
}
return selected
}
// GetVariant returns the variant at the given index, or nil if out of range.
func (m *ABRManifest) GetVariant(idx int) *Variant {
if idx < 0 || idx >= len(m.Variants) {
return nil
}
return &m.Variants[idx]
}
// WriteABRManifest writes the ABR manifest to a JSON file.
func WriteABRManifest(manifest *ABRManifest, path string) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("marshal ABR manifest: %w", err)
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write ABR manifest: %w", err)
}
return nil
}
// ReadABRManifest reads an ABR manifest from a JSON file.
func ReadABRManifest(path string) (*ABRManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read ABR manifest: %w", err)
}
return ParseABRManifest(data)
}
// ParseABRManifest parses an ABR manifest from JSON bytes.
func ParseABRManifest(data []byte) (*ABRManifest, error) {
var manifest ABRManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse ABR manifest: %w", err)
}
// Validate version
if manifest.Version != ABRVersion {
return nil, fmt.Errorf("unsupported ABR version: %s (expected %s)", manifest.Version, ABRVersion)
}
return &manifest, nil
}
// VariantFromSMSG creates a Variant from an existing .smsg file.
// It reads the header to extract chunk count and file size.
func VariantFromSMSG(name string, bandwidth, width, height int, smsgPath string) (*Variant, error) {
// Read file to get size and chunk info
data, err := os.ReadFile(smsgPath)
if err != nil {
return nil, fmt.Errorf("read smsg file: %w", err)
}
// Get header to extract chunk count
header, err := GetV3Header(data)
if err != nil {
return nil, fmt.Errorf("parse smsg header: %w", err)
}
chunkCount := 0
if header.Chunked != nil {
chunkCount = header.Chunked.TotalChunks
}
return &Variant{
Name: name,
Bandwidth: bandwidth,
Width: width,
Height: height,
Codecs: "avc1.640028,mp4a.40.2", // Default H.264 + AAC
URL: filepath.Base(smsgPath),
ChunkCount: chunkCount,
FileSize: int64(len(data)),
}, nil
}
// ABRBandwidthEstimator tracks download speeds for adaptive quality selection.
type ABRBandwidthEstimator struct {
samples []int // bandwidth samples in bps
maxSamples int
}
// NewABRBandwidthEstimator creates a new bandwidth estimator.
func NewABRBandwidthEstimator(maxSamples int) *ABRBandwidthEstimator {
if maxSamples <= 0 {
maxSamples = 10
}
return &ABRBandwidthEstimator{
samples: make([]int, 0, maxSamples),
maxSamples: maxSamples,
}
}
// RecordSample records a bandwidth sample from a download.
// bytes is the number of bytes downloaded, durationMs is the time in milliseconds.
func (e *ABRBandwidthEstimator) RecordSample(bytes int, durationMs int) {
if durationMs <= 0 {
return
}
// Calculate bits per second: (bytes * 8 * 1000) / durationMs
bps := (bytes * 8 * 1000) / durationMs
e.samples = append(e.samples, bps)
if len(e.samples) > e.maxSamples {
e.samples = e.samples[1:]
}
}
// Estimate returns the estimated bandwidth in bits per second.
// Uses average of recent samples, or 1 Mbps default if no samples.
func (e *ABRBandwidthEstimator) Estimate() int {
if len(e.samples) == 0 {
return 1000000 // 1 Mbps default
}
// Use average of last 3 samples (or all if fewer)
count := 3
if len(e.samples) < count {
count = len(e.samples)
}
recent := e.samples[len(e.samples)-count:]
sum := 0
for _, s := range recent {
sum += s
}
return sum / count
}

View file

@ -374,3 +374,43 @@ type Header struct {
// V3 chunked streaming (optional - enables decrypt-while-downloading)
Chunked *ChunkedInfo `json:"chunked,omitempty"` // chunk index for seeking/range requests
}
// ========== ADAPTIVE BITRATE STREAMING (ABR) ==========
// ABRManifest represents a multi-bitrate variant playlist for adaptive streaming.
// Similar to HLS master playlist but with encrypted SMSG variants.
type ABRManifest struct {
Version string `json:"version"` // "abr-v1"
Title string `json:"title"` // Content title
Duration int `json:"duration"` // Total duration in seconds
Variants []Variant `json:"variants"` // Quality variants (sorted by bandwidth, ascending)
DefaultIdx int `json:"defaultIdx"` // Default variant index (typically 720p)
Password string `json:"-"` // Shared password for all variants (not serialized)
}
// Variant represents a single quality level in an ABR stream.
// Each variant is a standard v3 chunked .smsg file.
type Variant struct {
Name string `json:"name"` // Human-readable name: "1080p", "720p", etc.
Bandwidth int `json:"bandwidth"` // Required bandwidth in bits per second
Width int `json:"width"` // Video width in pixels
Height int `json:"height"` // Video height in pixels
Codecs string `json:"codecs"` // Codec string: "avc1.640028,mp4a.40.2"
URL string `json:"url"` // Relative path to .smsg file
ChunkCount int `json:"chunkCount"` // Number of chunks (for progress calculation)
FileSize int64 `json:"fileSize"` // File size in bytes
}
// Standard ABR quality presets
var ABRPresets = []struct {
Name string
Width int
Height int
Bitrate string // For ffmpeg
BPS int // Bits per second
}{
{"1080p", 1920, 1080, "5M", 5000000},
{"720p", 1280, 720, "2.5M", 2500000},
{"480p", 854, 480, "1M", 1000000},
{"360p", 640, 360, "500K", 500000},
}

View file

@ -46,8 +46,11 @@ func main() {
"getInfo": js.FuncOf(smsgGetInfo),
"getInfoBinary": js.FuncOf(smsgGetInfoBinary), // Binary input (no base64!)
"quickDecrypt": js.FuncOf(smsgQuickDecrypt),
"version": Version,
"ready": true,
// ABR (Adaptive Bitrate Streaming) functions
"parseABRManifest": js.FuncOf(smsgParseABRManifest), // Parse ABR manifest JSON
"selectVariant": js.FuncOf(smsgSelectVariant), // Select best variant for bandwidth
"version": Version,
"ready": true,
}))
// Dispatch a ready event
@ -1650,3 +1653,106 @@ func jsToManifest(obj js.Value) *smsg.Manifest {
return manifest
}
// ========== ABR (Adaptive Bitrate Streaming) Functions ==========
// smsgParseABRManifest parses an ABR manifest from JSON string.
// JavaScript usage:
//
// const manifest = await BorgSMSG.parseABRManifest(jsonString);
// // Returns: {version, title, duration, variants: [{name, bandwidth, width, height, url, ...}], defaultIdx}
func smsgParseABRManifest(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 1 {
reject.Invoke(newError("parseABRManifest requires 1 argument: jsonString"))
return
}
jsonStr := args[0].String()
manifest, err := smsg.ParseABRManifest([]byte(jsonStr))
if err != nil {
reject.Invoke(newError("failed to parse ABR manifest: " + err.Error()))
return
}
// Convert to JS object
variants := make([]interface{}, len(manifest.Variants))
for i, v := range manifest.Variants {
variants[i] = map[string]interface{}{
"name": v.Name,
"bandwidth": v.Bandwidth,
"width": v.Width,
"height": v.Height,
"codecs": v.Codecs,
"url": v.URL,
"chunkCount": v.ChunkCount,
"fileSize": v.FileSize,
}
}
result := map[string]interface{}{
"version": manifest.Version,
"title": manifest.Title,
"duration": manifest.Duration,
"variants": variants,
"defaultIdx": manifest.DefaultIdx,
}
resolve.Invoke(js.ValueOf(result))
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}
// smsgSelectVariant selects the best variant for the given bandwidth.
// JavaScript usage:
//
// const idx = await BorgSMSG.selectVariant(manifest, bandwidthBPS);
// // Returns: index of best variant that fits within 80% of bandwidth
func smsgSelectVariant(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("selectVariant requires 2 arguments: manifest, bandwidthBPS"))
return
}
manifestObj := args[0]
bandwidthBPS := args[1].Int()
// Extract variants from JS object
variantsJS := manifestObj.Get("variants")
if variantsJS.IsUndefined() || variantsJS.Length() == 0 {
reject.Invoke(newError("manifest has no variants"))
return
}
// Build manifest struct
manifest := &smsg.ABRManifest{
Variants: make([]smsg.Variant, variantsJS.Length()),
}
for i := 0; i < variantsJS.Length(); i++ {
v := variantsJS.Index(i)
manifest.Variants[i] = smsg.Variant{
Bandwidth: v.Get("bandwidth").Int(),
}
}
// Select best variant
selectedIdx := manifest.SelectVariant(bandwidthBPS)
resolve.Invoke(selectedIdx)
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}