feat(stream): pion-based WebRTC byte streamer for browser playback
Replaces the broken anacrolix WebTorrent path with a custom WebRTC peer that the browser drives directly. Architecture matches plan/clever- weaving-dove.md (Fase 2 + 3 + 6 of the streaming pivot). - engine/wire: shared 12-byte binary frame format (Hello / RangeReq / RangeData / RangeEnd / Cancel / Ping / Pong / SeekHint). Roundtrip + oversized-frame rejection tests. - agent/signal_client: SSE consumer + POST sender for SDP/ICE relay through /api/internal/stream/signal/<id>; auto-reconnects. - engine/webrtc_stream: pion v4 PeerConnection + DataChannel pump. Reads file via os.ReadAt, chunks RangeData at 16 KiB, honours app- level backpressure with SetBufferedAmountLowThreshold. - cmd/daemon dispatcher learns mode webrtc_stream + new webrtcSessionRegistry tracks per-session cancel funcs for clean shutdown. - engine/probe + hwaccel + transcoder: foundation for Fase 2.5 (codec detection, NVENC/QSV/VAAPI/VideoToolbox autodetection, ffmpeg pipe wrapper to fragmented MP4). Integration into webrtc_stream still pending. - pion/webrtc/v4 promoted from indirect to direct dep. End-to-end against unarr-dev confirms a 122 MB 1080p H.264 / AAC MP4 plays in Chrome with the new pipeline.
This commit is contained in:
parent
4c52d9b039
commit
4314c06c5c
17 changed files with 2308 additions and 1 deletions
151
internal/engine/transcoder_test.go
Normal file
151
internal/engine/transcoder_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sliceContains(args []string, want string) bool {
|
||||
for _, a := range args {
|
||||
if a == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sliceContainsPair(args []string, key, val string) bool {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == key && args[i+1] == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsPassthroughCopy(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mp4", TranscodeOpts{
|
||||
Action: ActionPassthrough,
|
||||
HWAccel: HWAccelNone,
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
if !sliceContainsPair(args, "-c:v", "copy") {
|
||||
t.Errorf("passthrough should keep -c:v copy. args=%v", args)
|
||||
}
|
||||
if !sliceContainsPair(args, "-c:a", "copy") {
|
||||
t.Error("passthrough should keep -c:a copy")
|
||||
}
|
||||
if !sliceContainsPair(args, "-f", "mp4") {
|
||||
t.Error("output container must be mp4")
|
||||
}
|
||||
movflags := ""
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-movflags" {
|
||||
movflags = args[i+1]
|
||||
}
|
||||
}
|
||||
if !strings.Contains(movflags, "frag_keyframe") {
|
||||
t.Errorf("movflags must include frag_keyframe, got %q", movflags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsRemuxAudio(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
|
||||
Action: ActionRemuxAudio,
|
||||
AudioBitrate: "256k",
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
if !sliceContainsPair(args, "-c:v", "copy") {
|
||||
t.Error("remux-audio keeps video copy")
|
||||
}
|
||||
if !sliceContainsPair(args, "-c:a", "aac") {
|
||||
t.Error("remux-audio must transcode audio to aac")
|
||||
}
|
||||
if !sliceContainsPair(args, "-b:a", "256k") {
|
||||
t.Error("audio bitrate override not honored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsTranscodeVideoSoftware(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
|
||||
Action: ActionTranscodeVideo,
|
||||
HWAccel: HWAccelNone,
|
||||
Preset: "fast",
|
||||
VideoBitrate: "6M",
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
if !sliceContainsPair(args, "-c:v", "libx264") {
|
||||
t.Error("software fallback must use libx264")
|
||||
}
|
||||
if !sliceContainsPair(args, "-preset", "fast") {
|
||||
t.Error("custom preset not honored")
|
||||
}
|
||||
if !sliceContainsPair(args, "-b:v", "6M") {
|
||||
t.Error("video bitrate not honored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsTranscodeVideoNVENC(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
|
||||
Action: ActionTranscodeVideo,
|
||||
HWAccel: HWAccelNVENC,
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
if !sliceContainsPair(args, "-hwaccel", "cuda") {
|
||||
t.Error("NVENC must request -hwaccel cuda")
|
||||
}
|
||||
if !sliceContainsPair(args, "-c:v", "h264_nvenc") {
|
||||
t.Error("NVENC must use h264_nvenc encoder")
|
||||
}
|
||||
if sliceContains(args, "-preset") {
|
||||
// HW encoders ignore software preset; we should NOT pass it.
|
||||
t.Error("HW encoder path should not include -preset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsAddsStartSeek(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mp4", TranscodeOpts{
|
||||
Action: ActionPassthrough,
|
||||
StartSeconds: 90.5,
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
idxSs, idxIn := -1, -1
|
||||
for i, a := range args {
|
||||
if a == "-ss" {
|
||||
idxSs = i
|
||||
}
|
||||
if a == "-i" {
|
||||
idxIn = i
|
||||
}
|
||||
}
|
||||
if idxSs < 0 {
|
||||
t.Fatal("missing -ss flag")
|
||||
}
|
||||
if idxIn < 0 {
|
||||
t.Fatal("missing -i flag")
|
||||
}
|
||||
if idxSs >= idxIn {
|
||||
t.Errorf("expected -ss BEFORE -i for fast seek; got -ss@%d -i@%d", idxSs, idxIn)
|
||||
}
|
||||
if args[idxSs+1] != "90.500" {
|
||||
t.Errorf("expected seek 90.500s, got %q", args[idxSs+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsDownscale(t *testing.T) {
|
||||
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
|
||||
Action: ActionTranscodeVideo,
|
||||
HWAccel: HWAccelNone,
|
||||
MaxHeight: 720,
|
||||
FFmpegPath: "ffmpeg",
|
||||
})
|
||||
hasVF := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-vf" && strings.Contains(args[i+1], "720") {
|
||||
hasVF = true
|
||||
}
|
||||
}
|
||||
if !hasVF {
|
||||
t.Errorf("expected -vf scale containing 720; args=%v", args)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue