Skip to content

DLNA: passthrough copy, hardware/HEVC encoding, and configurable resolution#22

Merged
stupside merged 10 commits into
mainfrom
15-dlna-passthrough
Jul 20, 2026
Merged

DLNA: passthrough copy, hardware/HEVC encoding, and configurable resolution#22
stupside merged 10 commits into
mainfrom
15-dlna-passthrough

Conversation

@stupside

Copy link
Copy Markdown
Owner

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 ConnectionManager GetProtocolInfo negotiation (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 ffmpeg plus the Intel iHD driver (Wolfi packaged neither). Reaching the GPU in a container needs --device /dev/dri; without it, encoding falls back to libx264.

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. The GetProtocolInfo Sink 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.

stupside added 10 commits July 20, 2026 00:30
… 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
@stupside
stupside merged commit 8b55349 into main Jul 20, 2026
4 checks passed
@stupside
stupside deleted the 15-dlna-passthrough branch July 20, 2026 00:32
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DLNA path always re-encodes with libx264 - no passthrough (copy) even when the TV supports the source codec

1 participant