From 70f7337226ce4ce46b6bee7fba59ea4ed996115e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 7 May 2026 10:13:45 +0200 Subject: [PATCH] feat(stream): per-session quality cap from web Adds WebRTCSession.Quality to the sync payload so the daemon can pick a MaxHeight + bitrate per session instead of using the global config cap. resolveQualityCap() maps the label to a (height, b:v) pair and buildStreamSource() promotes a passthrough decision to ActionTranscodeVideo when the source resolution exceeds the cap (4K source on a phone client with quality="720p" must transcode, not pass-through). Also lands the transcode-on-by-default fix for legacy configs without a [downloads.transcode] section so existing installs pick up h264+aac fallback for HEVC/AC3 content without re-running setup. --- internal/agent/types.go | 3 ++ internal/cmd/daemon.go | 1 + internal/config/config.go | 26 ++++++++++++ internal/engine/webrtc_stream.go | 71 ++++++++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/internal/agent/types.go b/internal/agent/types.go index 0a67a20..82d70a4 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -362,6 +362,9 @@ type WebRTCSession struct { TaskID string `json:"taskId,omitempty"` FileName string `json:"fileName,omitempty"` FileSize int64 `json:"fileSize,omitempty"` + // Quality target the daemon should aim for when transcoding. One of + // "2160p" | "1080p" | "720p" | "480p" | "original" | "" (defer to config). + Quality string `json:"quality,omitempty"` } // SyncResponse is returned by the server with all pending actions for the CLI. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index aa0dd63..9845bd7 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -457,6 +457,7 @@ func runDaemonStart() error { FilePath: filePath, FileName: sess.FileName, FileSize: sess.FileSize, + Quality: sess.Quality, ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), Signal: agentClient, Logger: stdLogger{}, diff --git a/internal/config/config.go b/internal/config/config.go index b84655a..b7ee27d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -199,6 +199,32 @@ func Load(path string) (Config, error) { "stun:stun1.l.google.com:19302", } } + // Auto-enable transcode for the in-browser player when WebRTC is on + // AND the user hasn't explicitly opted out. The struct's Enabled + // field is `false` for legacy configs because the field didn't + // exist when they were written; we treat "no transcode section at + // all" as "use defaults" rather than "off". + tc := &cfg.Download.Transcode + if !tc.Enabled && tc.HWAccel == "" && tc.Preset == "" && tc.VideoBitrate == "" { + tc.Enabled = true + } + if tc.Enabled { + if tc.HWAccel == "" { + tc.HWAccel = "auto" + } + if tc.Preset == "" { + tc.Preset = "veryfast" + } + if tc.VideoBitrate == "" { + tc.VideoBitrate = "5M" + } + if tc.AudioBitrate == "" { + tc.AudioBitrate = "192k" + } + if tc.MaxConcurrent == 0 { + tc.MaxConcurrent = 2 + } + } } return cfg, nil diff --git a/internal/engine/webrtc_stream.go b/internal/engine/webrtc_stream.go index 3f04b3c..691d5a3 100644 --- a/internal/engine/webrtc_stream.go +++ b/internal/engine/webrtc_stream.go @@ -64,6 +64,10 @@ type WebRTCStreamConfig struct { // Transcode steers on-the-fly transcoding when source codecs are not // browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it. Transcode TranscodeRuntime + // Quality overrides the cap from Transcode for this session. One of + // "2160p" | "1080p" | "720p" | "480p" | "original" | "" (= defer to + // Transcode defaults). + Quality string } // TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so @@ -102,9 +106,37 @@ func logger(l StreamLogger) StreamLogger { return l } +// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate) +// pair. An empty label or "original" returns zero-values, signalling "no +// override" to the caller. +type qualityCap struct { + MaxHeight int + VideoBitrate string // ffmpeg -b:v string, e.g. "3500k" +} + +func resolveQualityCap(label string) qualityCap { + switch label { + case "2160p": + return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"} + case "1080p": + return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"} + case "720p": + return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"} + case "480p": + return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"} + default: + // "original", "auto", "" → defer to config. + return qualityCap{} + } +} + // buildStreamSource picks between passthrough and transcoded source. ffprobe // failure or missing ffmpeg falls back to passthrough — the browser surfaces // a clearer codec error than us refusing to start. +// +// Quality override (cfg.Quality) can force a downscale even when the source +// codec is browser-friendly: a 4K h264 file watched on a phone with quality +// "720p" must transcode (otherwise we'd ship 4K bytes for a 6" screen). func buildStreamSource( ctx context.Context, abs string, @@ -113,6 +145,8 @@ func buildStreamSource( log StreamLogger, ) (streamSource, error) { tc := cfg.Transcode + cap := resolveQualityCap(cfg.Quality) + if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" { return newDiskFileSource(abs) } @@ -123,27 +157,56 @@ func buildStreamSource( return newDiskFileSource(abs) } action := DecideAction(probe) + + // Quality cap can promote a passthrough/remux decision into a full video + // transcode when the source resolution exceeds the requested cap. + forcedByQuality := false + if cap.MaxHeight > 0 && probe.Height > 0 && probe.Height > cap.MaxHeight { + if action != ActionTranscodeVideo { + log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode", + agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, cap.MaxHeight) + action = ActionTranscodeVideo + forcedByQuality = true + } + } + if action == ActionPassthrough { log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)", agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container) return newDiskFileSource(abs) } - log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s)", - agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec, action) + log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)", + agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec, + action, coalesceLabel(cfg.Quality)) + + maxHeight := tc.MaxHeight + videoBitrate := tc.VideoBitrate + if cap.MaxHeight > 0 { + maxHeight = cap.MaxHeight + videoBitrate = cap.VideoBitrate + } + _ = forcedByQuality // reserved for future telemetry opts := TranscodeOpts{ Action: action, HWAccel: tc.HWAccel, Preset: tc.Preset, - VideoBitrate: tc.VideoBitrate, + VideoBitrate: videoBitrate, AudioBitrate: tc.AudioBitrate, - MaxHeight: tc.MaxHeight, + MaxHeight: maxHeight, FFmpegPath: tc.FFmpegPath, } return newTranscodeSource(ctx, abs, probe, action, opts, displayName) } +func coalesceLabel(s string) string { + if s == "" { + return "default" + } + return s +} + // RunWebRTCStream blocks until the session ends — either the DataChannel // closes, the peer connection drops, or ctx is cancelled. Always returns a // non-nil error explaining the termination reason.