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:
parent
8486242fd8
commit
63b8a3ecb6
6 changed files with 1046 additions and 6 deletions
|
|
@ -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
226
cmd/mkdemo-abr/main.go
Normal 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()
|
||||
}
|
||||
362
demo/index.html
362
demo/index.html
|
|
@ -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
214
pkg/smsg/abr.go
Normal 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
|
||||
}
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue