DLNA: passthrough copy, hardware/HEVC encoding, and configurable resolution#22
Merged
Conversation
… encode otherwise planDLNA always re-encoded to libx264, pinning the CPU on low-power hosts even when the TV plays the source H.264 natively (issue #15). Decide copy-vs-encode from a local spool probe (probing the upstream signed URL would burn the short-lived token): stream-copy the video when the source matches an envelope the renderer decodes natively, otherwise re-encode. Structured in layers with one-way dependencies: - media: the domain vocabulary and rules. Codec, ProbeInfo, and a Renderer capability (containers + video envelopes) with the copy/accept decision. Pure: no ffmpeg, no exec. - device: each device type owns its capabilities (device.Capabilities returns a media.Renderer); the Samsung H.264 envelope lives with the DLNA device. - ffmpeg: a thin ffmpeg/ffprobe adapter. Probe returns media.ProbeInfo. An Encoder is a plain struct (a -c:v name plus the arg fragments it adds); SelectH264Encoder returns a hardware encoder if a real test encode passes (VA-API on Linux, VideoToolbox on macOS, chosen by a build-tagged hardwareH264 function, no interface and no registry) else libx264. - cast: thin orchestration wiring the three together. Adding a codec or renderer is a data change in media/device; adding a hardware encoder is a struct value plus one line in a platform file. Switch the Docker image to debian bookworm-slim so it carries a vaapi-enabled ffmpeg plus the Intel iHD driver (intel-media-va-driver-non-free, amd64 only); Wolfi shipped neither. Document the --device /dev/dri runtime requirement.
The re-encode path emitted unbounded ABR (-b:v only), so complex scenes spiked well above the HTTP pacer's fixed send rate and drained the TV's buffer. Cap the VBV (-maxrate/-bufsize) so the send rate is a real ceiling, force 8-bit yuv420p (a 10-bit source otherwise encodes to an undecodable High 10 stream on Samsung), pin a ~2s keyframe cadence via force_key_frames (VideoToolbox also needs -g to lift its sub-second default GOP), and pace the subtitle encode just above realtime (readrate 1.15) so the renderer's buffer rebuilds after jitter instead of only eroding.
Replace SelectH264Encoder and the per-GOOS hardwareH264 build-tagged files with one codec-parameterized registry. Each Encoder now carries the codec it produces and its own ffmpeg fragments, and SelectEncoder(codec) returns the best working one: a hardware encoder proven by a real test encode, else the always-available software baseline. Platform is no longer assumed; every backend is a candidate and the probe discovers what actually works here, so VA-API on macOS or VideoToolbox on Linux is simply skipped rather than compiled out. Adding a codec is a registry entry, not new control flow: HEVC is registered (software, VideoToolbox, VA-API) and verified to encode, though the pipeline still targets H.264 until the renderer's advertised codecs are discovered.
Replace the static per-device-type Capabilities table (a hardcoded H.264-only DLNA envelope) with a Capabilities() method each Device resolves from itself. DLNA negotiates it at connect over ConnectionManager GetProtocolInfo, parsing the renderer's advertised Sink into the codecs and containers it accepts and pairing each codec with a safe copy envelope, so a renderer that advertises HEVC now lights up HEVC copy with no code change. Negotiation is best-effort: an unparseable or empty Sink degrades to a conservative H.264-in-TS floor so playback still works. Chromecast reports its known receiver profile through the same method. The spooled pipeline now awaits the connected renderer before the copy-vs-encode decision (connect overlaps the pull and gate, so it never delays first byte) and drives that decision from what the renderer advertised.
The re-encode path no longer hardcodes H.264. selectVideoEncoder walks a preference list (HEVC before H.264) and picks the first codec both the renderer advertises (Renderer.SupportsCodec, from the negotiated capabilities) and this host can produce, taking a codec above H.264 only when a hardware encoder backs it (software HEVC can't hold realtime at 1080p) and falling back to the universal H.264 baseline otherwise. Bitrate targets and pacing are now per codec (dlnaVideoTargets): HEVC aims for half of H.264, halving what the pacer moves. So an HEVC-capable TV on a VideoToolbox/VA-API host gets hardware HEVC at ~2M with no code change, while everything else degrades cleanly to H.264.
Three fixes from a review of the DLNA rework: - Bound the GetProtocolInfo round-trip with a timeout so a slow or broken ConnectionManager degrades to conservative capabilities instead of stalling the cast (the negotiation now gates the copy-vs-encode decision). - Inject the encoder selector into selectVideoEncoder so the codec-ladder logic (prefer hardware HEVC, never software HEVC for live, fall back to H.264) is unit tested rather than only exercised end to end. - Add a test asserting every codec in codecPreference has a bitrate target, so a future codec cannot silently transcode unbounded (no -maxrate).
Stop hardcoding the stream-copy cap at 1080p. The copy envelope's resolution and level are now filled from the renderer's advertised DLNA resolution class (parsed from the PN token: HD to 1080p, UHD/4K to 2160p), so a 4K TV copies 4K natively while an HD-only one stays safely capped. A codec advertised without a parseable class is trusted up to 4K (defaultCopyHeight); total negotiation failure still falls back to a conservative 1080p H.264. The genuine black-screen gates (10-bit H.264, HDR) are unchanged. The re-encode fallback still targets 1080p, since 4K transcode needs resolution-scaled bitrates and encode load out of scope here.
…essing Resolution becomes a user choice (resolver.max_height, required, default 1080) rather than something reverse-engineered from the TV's DLNA PN tokens. Source selection now prefers the largest HLS variant no taller than max_height, by surfacing the RESOLUTION tag the parser had been discarding; the encoder scales its output down to it; and copy is gated on the real probed source height. With the source already at the resolution the user chose, the df59874 PN-token guessing (resolutionHeight, defaultCopyHeight, maxLevelForHeight, and the MaxHeight/MaxLevel fields on VideoSupport) is deleted: the renderer copy envelope collapses to what actually black-screens a TV (codec, profile, bit depth, HDR). The GetProtocolInfo codec negotiation is unchanged.
RankStreams now surfaces the probed video height (media.StreamInfo.VideoHeight) and prefers a candidate within resolver.max_height over one that exceeds it, so two direct streams at different resolutions no longer pull the 4K one just to downscale it. HLS masters are exempt: a master carries every variant and its single-variant probe height is not a real ceiling, so it is ranked on bandwidth and capped later when Resolve picks a variant. Ties and the all-over-cap case fall back to highest bandwidth, as before.
- drop the no-op field clearing on the copy path (EncodeArgs ignores bitrate, maxrate, bufsize and max-height when the encoder is nil) - remove the unused CodecVP9 constant and the never-set AllowHDR extension point (HDR is unconditionally rejected, as before) - deduplicate the resolution and nil-field comments that repeated the domain type - hoist the single renderer Capabilities() lookup
Klaudioz
added a commit
to Klaudioz/castor
that referenced
this pull request
Jul 20, 2026
Service lookup matched only "urn:schemas-upnp-org:service:AVTransport:1",
an exact string compare, so renderers publishing a newer version failed to
connect even though SSDP discovery listed them: version-tolerant M-SEARCH
answers a MediaRenderer:1 probe from a MediaRenderer:3 device, and the
mismatch only surfaced later when parsing the description.
creating AVTransport client: goupnp: service
"urn:schemas-upnp-org:service:AVTransport:1" not found within device
Look services up across v3, v2 and v1, newest first, via a shared
findService helper. UPnP requires a higher service version to stay
backward compatible with the actions of lower ones, and castor calls only
SetAVTransportURI, Play and GetProtocolInfo, unchanged since v1.
Apply the same treatment to the ConnectionManager lookup added in stupside#22.
That one fails soft rather than hard: a renderer publishing only
ConnectionManager:3 degrades to fallbackCaps, so it re-encodes streams it
could have copied. The copy path was unreachable on exactly the renderers
this commit makes connectable.
Hold generic goupnp.ServiceClients rather than the av1 wrappers: those
hardcode the v1 URN as the SOAP action namespace, which a strict v3
service can reject as an invalid action.
Observed on a Philips 50PUD6654/43, which exposes RenderingControl:3,
ConnectionManager:3 and AVTransport:3 only.
Claude-Session: https://claude.ai/code/session_01SapjS1H29hRzRbzJin212o
stupside
pushed a commit
that referenced
this pull request
Jul 20, 2026
Service lookup matched only "urn:schemas-upnp-org:service:AVTransport:1",
an exact string compare, so renderers publishing a newer version failed to
connect even though SSDP discovery listed them: version-tolerant M-SEARCH
answers a MediaRenderer:1 probe from a MediaRenderer:3 device, and the
mismatch only surfaced later when parsing the description.
creating AVTransport client: goupnp: service
"urn:schemas-upnp-org:service:AVTransport:1" not found within device
Look services up across v3, v2 and v1, newest first, via a shared
findService helper. UPnP requires a higher service version to stay
backward compatible with the actions of lower ones, and castor calls only
SetAVTransportURI, Play and GetProtocolInfo, unchanged since v1.
Apply the same treatment to the ConnectionManager lookup added in #22.
That one fails soft rather than hard: a renderer publishing only
ConnectionManager:3 degrades to fallbackCaps, so it re-encodes streams it
could have copied. The copy path was unreachable on exactly the renderers
this commit makes connectable.
Hold generic goupnp.ServiceClients rather than the av1 wrappers: those
hardcode the v1 URN as the SOAP action namespace, which a strict v3
service can reject as an invalid action.
Observed on a Philips 50PUD6654/43, which exposes RenderingControl:3,
ConnectionManager:3 and AVTransport:3 only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15.
The DLNA path always re-encoded to software
libx264, which pinned the CPU on low-power hosts even when the TV could play the source natively. This reworks it into a discovery-driven pipeline that only re-encodes when it has to.Passthrough
Probes the source and stream-copies (
-c:v copy) when the codec is one the renderer decodes and the stream is inside a safe envelope (profile, 8-bit, non-HDR). Video encoding drops to zero; audio is still normalized to AAC. Renderer capabilities come from a real ConnectionManagerGetProtocolInfonegotiation (codec + container), degrading to a conservative H.264-in-TS floor when a renderer returns nothing usable.Hardware + HEVC encoding
When a re-encode is unavoidable (a codec the TV rejects, or burned-in subtitles), the encoder is chosen from a codec-agnostic registry: hardware first (VideoToolbox on macOS, VA-API on Linux), gated by a real one-frame test encode, else the software baseline. It prefers HEVC (about half the bitrate) when the renderer advertises it and a hardware HEVC encoder is present, else H.264. Adding a codec or backend is a registry entry, not new control flow.
The Docker image moves from Wolfi to Debian so it ships a VA-API
ffmpegplus the Intel iHD driver (Wolfi packaged neither). Reaching the GPU in a container needs--device /dev/dri; without it, encoding falls back tolibx264.Configurable resolution
resolver.max_height(default 1080) is the single knob for cast resolution: source selection prefers the largest HLS variant no taller than it (and, among direct candidates, the in-cap one), the encoder scales its output down to it, and copy is gated on the probed source height. Resolution is a user preference, not reverse-engineered from vendor DLNA tokens.Also
Bounded VBV bitrate, a 2s keyframe cadence, and forced 8-bit output fix a separate rebuffering issue on the transcode path.
Testing
go build,go vet,gofmt, and the unit suite pass, covering the encoder registry, capability parsing, the codec ladder, variant/candidate selection, and config defaults. TheGetProtocolInfoSink parsing is unit-tested but has not been run against a physical Samsung set, so one real-device smoke is worth doing before relying on HEVC or negotiation in the field.