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.
This commit is contained in:
Deivid Soto 2026-05-07 10:13:45 +02:00
parent 66ac79664b
commit 70f7337226
4 changed files with 97 additions and 4 deletions

View file

@ -362,6 +362,9 @@ type WebRTCSession struct {
TaskID string `json:"taskId,omitempty"` TaskID string `json:"taskId,omitempty"`
FileName string `json:"fileName,omitempty"` FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,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. // SyncResponse is returned by the server with all pending actions for the CLI.

View file

@ -457,6 +457,7 @@ func runDaemonStart() error {
FilePath: filePath, FilePath: filePath,
FileName: sess.FileName, FileName: sess.FileName,
FileSize: sess.FileSize, FileSize: sess.FileSize,
Quality: sess.Quality,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
Signal: agentClient, Signal: agentClient,
Logger: stdLogger{}, Logger: stdLogger{},

View file

@ -199,6 +199,32 @@ func Load(path string) (Config, error) {
"stun:stun1.l.google.com:19302", "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 return cfg, nil

View file

@ -64,6 +64,10 @@ type WebRTCStreamConfig struct {
// Transcode steers on-the-fly transcoding when source codecs are not // Transcode steers on-the-fly transcoding when source codecs are not
// browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it. // browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it.
Transcode TranscodeRuntime 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 // TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
@ -102,9 +106,37 @@ func logger(l StreamLogger) StreamLogger {
return l 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 // buildStreamSource picks between passthrough and transcoded source. ffprobe
// failure or missing ffmpeg falls back to passthrough — the browser surfaces // failure or missing ffmpeg falls back to passthrough — the browser surfaces
// a clearer codec error than us refusing to start. // 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( func buildStreamSource(
ctx context.Context, ctx context.Context,
abs string, abs string,
@ -113,6 +145,8 @@ func buildStreamSource(
log StreamLogger, log StreamLogger,
) (streamSource, error) { ) (streamSource, error) {
tc := cfg.Transcode tc := cfg.Transcode
cap := resolveQualityCap(cfg.Quality)
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" { if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
return newDiskFileSource(abs) return newDiskFileSource(abs)
} }
@ -123,27 +157,56 @@ func buildStreamSource(
return newDiskFileSource(abs) return newDiskFileSource(abs)
} }
action := DecideAction(probe) 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 { if action == ActionPassthrough {
log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)", log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)",
agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container) agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container)
return newDiskFileSource(abs) return newDiskFileSource(abs)
} }
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s)", log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec, action) 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{ opts := TranscodeOpts{
Action: action, Action: action,
HWAccel: tc.HWAccel, HWAccel: tc.HWAccel,
Preset: tc.Preset, Preset: tc.Preset,
VideoBitrate: tc.VideoBitrate, VideoBitrate: videoBitrate,
AudioBitrate: tc.AudioBitrate, AudioBitrate: tc.AudioBitrate,
MaxHeight: tc.MaxHeight, MaxHeight: maxHeight,
FFmpegPath: tc.FFmpegPath, FFmpegPath: tc.FFmpegPath,
} }
return newTranscodeSource(ctx, abs, probe, action, opts, displayName) 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 // RunWebRTCStream blocks until the session ends — either the DataChannel
// closes, the peer connection drops, or ctx is cancelled. Always returns a // closes, the peer connection drops, or ctx is cancelled. Always returns a
// non-nil error explaining the termination reason. // non-nil error explaining the termination reason.