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:
parent
66ac79664b
commit
70f7337226
4 changed files with 97 additions and 4 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue