diff --git a/RFC-001-OSS-DRM.md b/RFC-001-OSS-DRM.md index d7e147e..baf4e76 100644 --- a/RFC-001-OSS-DRM.md +++ b/RFC-001-OSS-DRM.md @@ -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 diff --git a/cmd/mkdemo-abr/main.go b/cmd/mkdemo-abr/main.go new file mode 100644 index 0000000..f1902aa --- /dev/null +++ b/cmd/mkdemo-abr/main.go @@ -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 [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 [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() +} diff --git a/demo/index.html b/demo/index.html index cea98ca..39c1e9a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1490,6 +1490,7 @@
+
@@ -1575,6 +1576,86 @@
+ + +
+

+ 📶 Adaptive Bitrate Streaming +

+

+ Automatic quality switching based on your network speed. Like HLS/DASH but with ChaCha20-Poly1305 encryption. +

+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + + + + + + + + +
+
+ ℹ️ How ABR Works +
+
+

1. Manifest lists multiple quality variants (1080p, 720p, 480p, 360p)

+

2. Player measures your download speed

+

3. Automatically switches to best quality for your connection

+

4. Same password decrypts all variants

+
+
+
@@ -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 = ''; + manifest.variants.forEach((v, i) => { + const bw = v.bandwidth >= 1000000 + ? (v.bandwidth / 1000000).toFixed(1) + ' Mbps' + : (v.bandwidth / 1000) + ' Kbps'; + select.innerHTML += ``; + }); + } + + // 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 = ` +
+ +
+
+ Playing: ${abrManifest.title} @ ${variant.name} +
+ `; + + 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'; + } + }); diff --git a/pkg/smsg/abr.go b/pkg/smsg/abr.go new file mode 100644 index 0000000..04e582f --- /dev/null +++ b/pkg/smsg/abr.go @@ -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 +} diff --git a/pkg/smsg/types.go b/pkg/smsg/types.go index c8a99f3..b96acda 100644 --- a/pkg/smsg/types.go +++ b/pkg/smsg/types.go @@ -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}, +} diff --git a/pkg/wasm/stmf/main.go b/pkg/wasm/stmf/main.go index 6a54882..3cba666 100644 --- a/pkg/wasm/stmf/main.go +++ b/pkg/wasm/stmf/main.go @@ -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) +}