Files
silo-server/internal/playback/plan_v3.go
T
ee9356aab3 fix(playback): claim Safari DV/HDR10 decode evidence and preserve dvvC in remuxes (#617)
* fix(playback): claim Safari DV/HDR10 decode evidence and preserve dvvC

Follow-up to #613 for #609: Safari 26 reports dynamic-range: standard even
on an XDR display, and answers canPlayType "probably" only for dvh1/hvc1
sample entries, never dvhe/hev1. The web probe gated every structured HDR
claim on that media query and probed only dvhe, so the planner still saw
empty hdr_details and terminated with hdr_transcode_unsupported.

- Run the Dolby Vision and HDR10 shape probes unconditionally; the
  dynamic-range query survives only as the best-effort hdr output boolean.
- Probe dvh1+dvhe per DV profile and hvc1+hev1 for the exact HDR10
  Media Capabilities shape; either definitive answer earns the claim.
- Tag preserved-DV remuxes dvh1 with -strict unofficial: FFmpeg omits the
  dvvC configuration record under either tag without it, so the previous
  dvhe output carried no DV signaling at all.
- Scale the screen-derived max_resolution by devicePixelRatio so 2x
  panels stop advertising 1080p.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): align DV/HDR10 claims with the sample entries the remux emits

Review follow-ups on #617 (Codex P1s):

- Probe only dvh1: a browser answering "probably" solely for dvhe has
  given no evidence for the dvh1-tagged file the preserve remux delivers,
  so that answer no longer earns a Dolby Vision claim; such browsers keep
  the validated HDR10 fallback.
- Label the explicit v3 HDR10 strip output hvc1 so the file matches the
  hvc1 evidence the web probe accepts; legacy/auto strips keep hev1.
- Cover preserve tagging for profiles 5 and 8, and exercise the
  mode-to-argument mapping through StartRemuxWithDVMode instead of
  passing the tag flag directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): name the subtitle when burn-in alone forces a refused transcode

Selecting a PGS/VOBSUB track on a client without a bitmap renderer forces
a burn-in transcode. When that transcode cannot run, the terminal blamed
the HDR pipeline (hdr_transcode_unsupported) or the 4K policy
(no_alternate_version) — problems that were not blocking playback, since
deselecting the subtitle restores the previous route.

- When the burn requirement is the sole trigger of the adaptation, the
  HDR and 4K-policy terminals emit subtitle_conversion_unsupported with
  a message naming the burn requirement and the actual blocker. A range
  the client genuinely cannot take keeps the HDR terminal.
- describePlanTerminal passes the server's subtitle message through
  instead of flattening every subtitle_* reason to one generic sentence.
- The web player toasts the refusal when it rolls the subtitle selection
  back; previously the only surface was the quality menu, which a user
  who just picked a subtitle has no reason to open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): require decode evidence for the exact hvc1 entry and gate the disabled-transcode subtitle terminal

Review follow-ups on #617:

- The HDR10 probe accepts only hvc1.2.4.L153.B0: the explicit v3 strip
  remux labels its output hvc1, so an hev1-only decodingInfo answer is
  evidence for bytes Silo never sends and earns no claim (Codex P1).
- The disabled-transcode branch blames the subtitle only when the burn
  requirement was the sole adaptation trigger; other causes keep
  transcoding_disabled. In practice a bitmap selection without transcode
  terminals in the subtitle policy before this branch, but the guard
  keeps the sole-cause invariant if that ordering ever changes
  (CodeRabbit / Codex P2).
- Protocol doc scopes the hvc1 labeling to the explicit v3 strip path;
  legacy/auto strips keep hev1 (CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:14:52 -04:00

1300 lines
64 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package playback
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type PlannerSettingsV3 struct {
TranscodeEnabled bool
Allow4KTranscode bool
}
const (
TerminalMessage4KTranscodeDisabledV3 = "A lower-resolution source is required because 4K transcoding is disabled."
containerMP4V3 = "mp4"
mimeVideoMP4V3 = "video/mp4"
degradationAudioConvertedV3 = "audio_converted"
audioLayoutMonoV3 = "mono"
audioLayoutStereoV3 = "stereo"
audioLayoutSurround51V3 = "5.1"
)
type PlannerInputV3 struct {
Request StartRequestV3
RequestedFile *models.MediaFile
EffectiveFile *models.MediaFile
AudioTrackIndex int
Settings PlannerSettingsV3
// Registry holds the transformations the local binary can execute.
// Progressive remux routes always gate on it: they run in this process.
Registry *TransformationRegistryV3
// HLSRegistry optionally widens transformation availability for HLS
// deliveries, which can execute on pooled transcode nodes as well as
// locally. Nil means HLS routes gate on Registry alone. It is a lazy
// producer because building the widened registry can touch the network
// (node capability fetches): the planner only invokes it when a route
// decision genuinely depends on node capabilities, so direct-play and
// other source-preserving starts never pay for it. Producers must
// return a superset of Registry (local ∪ node capabilities) and should
// memoize; the transport layer re-validates whichever executor is
// actually selected.
HLSRegistry func() *TransformationRegistryV3
// DVRPUStrippable reports whether this particular source survives the
// Dolby Vision RPU strip. The registries answer whether the executor
// carries the transformation; this answers whether the file does, which
// no capability probe can. Nil means "assume it does", preserving the
// pre-probe behaviour for callers that cannot run one (the shadow
// planner, tests). Lazy for the same reason as HLSRegistry: it shells out
// to ffmpeg, so it is consulted only once every cheap eligibility gate
// has already passed and a strip route is genuinely on the table.
DVRPUStrippable func() bool
Now time.Time
AttemptedKeys []string
AdditionalSubtitles []SubtitleInventoryEntryV3
}
// SourceExecutionMetadataV3 is the immutable source probe snapshot used to
// reopen a frozen playback recipe without consuming later catalog drift.
type SourceExecutionMetadataV3 struct {
VideoCodec string
SoftwareVideoDecode bool
DurationSeconds float64
}
// dvRPUStrippable resolves the per-source strip verdict, defaulting to true
// when no probe is wired in.
func (input PlannerInputV3) dvRPUStrippable() bool {
return input.DVRPUStrippable == nil || input.DVRPUStrippable()
}
// hlsRegistry resolves the registry HLS deliveries gate on: the widened
// local∪node registry when provided, otherwise the local one. Callers must
// keep it behind short-circuits so transformation-free routes never force
// the lazy producer to run.
func (input PlannerInputV3) hlsRegistry() *TransformationRegistryV3 {
if input.HLSRegistry != nil {
if widened := input.HLSRegistry(); widened != nil {
return widened
}
}
return input.Registry
}
type PlannerResultV3 struct {
Plan *PlanV3
Terminal *TerminalV3
PlayMethod PlayMethod
TranscodeAudio bool
TargetVideoCodec string
TargetAudioCodec string
// TargetAudioChannels caps the transcode's re-encoded channel count;
// 0 keeps the historical stereo downmix.
TargetAudioChannels int
TargetAudioBitrateKbps int
TargetResolution string
TargetBitrateKbps int
SubtitleTrackIndex int
SubtitleTransportTrackIndex int
SubtitleBurnIn bool
SubtitleCodec string
// DownloadedSubtitleID comes from the same inventory snapshot used for
// planning. Freezing must not re-list a mutable ordinal inventory after the
// route has already been accepted.
DownloadedSubtitleID int
// FrozenSourceMetadata is set only when a durable executable recipe is
// thawed for a seek reanchor. Transport construction must then use this
// captured source snapshot instead of a freshly probed media row.
FrozenSourceMetadata *SourceExecutionMetadataV3
}
func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
if input.RequestedFile == nil {
return terminalPlannerResultV3("source_unavailable", "The requested media source is unavailable.", false)
}
file := input.EffectiveFile
if file == nil {
file = input.RequestedFile
}
if input.Now.IsZero() {
input.Now = time.Now()
}
source := SourceDescriptorFromFileV3(file, input.AudioTrackIndex)
// A source without any video track is audio-only (audiobooks, future
// music): the video, HDR, and subtitle-burn gates below have nothing to
// gate, and requiring complete video metadata would terminal a perfectly
// playable file. It gets its own reduced route family instead.
if file.IsAudioOnly() {
return planAudioOnlyV3(input, file, source)
}
// Subtitle renderability is delivery-specific, so every candidate route is
// validated against the capabilities of the delivery class that would
// execute it. The original_http delivery remains the canonical policy for
// source-preserving routes and for the up-front terminal decision.
subtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, DeliveryClassOriginalHTTPV3, input.AdditionalSubtitles)
if subtitle.Terminal != nil {
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
remuxSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, DeliveryClassProgressiveV3, input.AdditionalSubtitles)
hlsSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, DeliveryClassHLSV3, input.AdditionalSubtitles)
// A remux route cannot burn subtitles, so it is only viable when its own
// delivery can present the selected subtitle without one.
remuxSubtitleOK := remuxSubtitle.Terminal == nil && !remuxSubtitle.RequiresBurn
hlsRemuxSubtitleOK := hlsSubtitle.Terminal == nil && !hlsSubtitle.RequiresBurn
quality := ResolveQualityPolicyV3(input.Request, source)
videoOK, videoEvidenceInsufficient := videoEligibleV3(source, input.Request)
var high10Quirk *AppliedQuirkV3
if !videoOK {
if quirk, ok := high10DecodeOverrideV3(source, input.Request); ok {
videoOK = true
high10Quirk = quirk
}
}
rangeOK, videoClaims := outputRangeEligibleV3(source, input.Request)
audioOK, passthrough, audioClaims := audioEligibilityV3(source, input.Request)
if !audioOK && source.AudioCodec == "" && (file == nil || len(file.AudioTracks) == 0) {
// Video-only media has no audio stream to adapt: treating the absence
// as an unsupported codec would force a pointless AAC conversion — or
// a terminal when conversion is unavailable — on a playable file. An
// audio track whose codec merely failed to probe keeps the codec
// gate: converting unknown audio is safer than copying it.
audioOK = true
audioClaims.Reason = "no_audio_track"
}
containerOK := containsFoldV3(input.Request.Capabilities.Containers, source.Container)
hlsDeliveryOK := deliveryAvailableV3(input.Request, DeliveryClassHLSV3)
// DV strip eligibility is split by executor pool: a progressive remux
// executes on this process's ffmpeg, while an HLS remux may run on a
// pooled transcode node advertising the transformation. Node capability
// only counts when the client can actually run an HLS delivery, and the
// widened registry is consulted lazily so non-DV sources never touch it.
dvStripEligibleLocal := canStripDolbyVisionToHDR10V3(source, input.Request, input.Registry)
dvStripEligible := dvStripEligibleLocal
if !dvStripEligible && hlsDeliveryOK && source.DynamicRange == DynamicRangeDolbyVisionV3 {
dvStripEligible = canStripDolbyVisionToHDR10V3(source, input.Request, input.hlsRegistry())
}
// A source whose RPU ffmpeg cannot parse must lose the strip here rather
// than at the transport, so that the plan's HDR10 promise, the durable
// session's RemuxDVMode and every restart derived from it stay consistent
// with what the pipeline can actually produce. Ordered last: the probe
// only runs once an executor has been found for a strip this client wants.
dvStripUnsupportedBySource := false
if dvStripEligible && !input.dvRPUStrippable() {
dvStripUnsupportedBySource = true
dvStripEligible = false
dvStripEligibleLocal = false
}
clientDV81Eligible := canClientTransformDV7ToDV81V3(source, input.Request)
clientHDR10Eligible := canClientTransformDV7ToHDR10V3(source, input.Request)
// With the server strip gone, a client that cannot take the source range
// and cannot run its own DV transformation has no route left: this
// codebase has no tone-map recipe, so every remaining branch funnels into
// planVideoTranscodeV3's hdr_transcode_unsupported. Terminate here instead
// so the client is told the actual cause — a source whose Dolby Vision
// metadata cannot be removed — rather than a generic HDR message that
// sends the user looking for a missing encoder.
if dvStripUnsupportedBySource && !rangeOK && !clientDV81Eligible && !clientHDR10Eligible {
return terminalPlannerResultV3(TerminalDVConversionUnsupportedV3,
"This source's Dolby Vision metadata cannot be removed cleanly, and this device cannot play the source as it is.", false)
}
base := PlanV3{
ProtocolVersion: ProtocolV3,
ExpiresAt: NewPlanExpiryV3(input.Now),
SelectedTracks: selectedTracksForPlanV3(file, input.AudioTrackIndex, subtitle),
EffectiveRecipe: recipeFromSourceV3(source),
Claims: ValidationClaimsV3{Video: videoClaims, Audio: audioClaims, Subtitles: subtitle.Claims},
Subtitle: subtitle.Decision,
Transformations: []TransformationV3{},
AppliedQuirks: []AppliedQuirkV3{},
RuntimeCorrections: []string{},
DegradationWarnings: []DegradationWarningV3{},
RequestedMediaFileID: input.RequestedFile.ID,
EffectiveMediaFileID: file.ID,
Source: source,
SubtitleFidelityPolicy: subtitlePolicyNameV3(input.Request.SubtitleFidelityPreference),
Timeline: TimelineV3{SourceStartSeconds: floatOrZeroV3(input.Request.StartPosition), PlayerStartSeconds: floatOrZeroV3(input.Request.StartPosition), CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
base.AvailableQualities = availableQualitiesV3(input, source)
base.Subtitle.Inventory = BuildSubtitleInventoryV3(file, input.AdditionalSubtitles)
base.Claims.Audio.Passthrough = passthrough
if source.DynamicRange == "hdr_unknown" && rangeOK {
base.DegradationWarnings = append(base.DegradationWarnings, DegradationWarningV3{
Code: "hdr_range_assumed_hdr10",
Message: "The source is flagged HDR without precise range metadata and is delivered as HDR10.",
})
}
if dvStripUnsupportedBySource {
// Say why the HDR10 route this client is capable of was not taken;
// otherwise the fallback looks like an unexplained quality drop.
base.DegradationWarnings = append(base.DegradationWarnings, DegradationWarningV3{
Code: "dolby_vision_strip_unsupported_by_source",
Message: "This source's Dolby Vision metadata cannot be removed cleanly, so the validated HDR10 route is unavailable for it.",
})
}
if !routeVideoMetadataCompleteV3(source) {
return terminalPlannerResultV3("source_metadata_incomplete", "The source is missing video metadata required for a validated playback route.", true)
}
if !videoOK && videoEvidenceInsufficient {
// The client's flat codec lists claim this stream, but its evidence
// tier could not validate it for a direct route. Distinguish that from
// a device that genuinely cannot play the stream so lower-tier clients
// see an actionable degradation instead of a mystery transcode.
base.DegradationWarnings = append(base.DegradationWarnings, DegradationWarningV3{
Code: EvidenceInsufficientForDirectV3,
Message: "The client's capability evidence tier cannot validate this stream for a direct route; an adapted route is used instead.",
})
}
// Automatic quality reductions (device resolution limit, bandwidth
// estimate/cap, metered fallback) are best-effort. When the only reason to
// transcode is such a reduction, a validated source-preserving route
// exists, and the transcode itself cannot execute (HDR sources have no
// validated reduced-quality recipe yet, or the client/server lacks the
// transcode route entirely), deliver the source at original quality with a
// degradation warning instead of refusing playback. Explicit user-selected
// rungs keep the existing terminals.
if quality.RequiresTranscode && !quality.ExplicitRung && !subtitle.RequiresBurn && videoOK &&
(rangeOK || dvStripEligible || clientDV81Eligible || clientHDR10Eligible) &&
!videoTranscodeExecutableV3(input, source) {
warnings := append(quality.Warnings, DegradationWarningV3{
Code: "quality_reduction_unavailable",
Message: "Reduced-quality transcoding is unavailable for this source; it is delivered at original quality.",
})
quality = originalQualityResultV3(source)
quality.Warnings = warnings
}
base.DegradationWarnings = append(base.DegradationWarnings, quality.Warnings...)
if quality.RequiresTranscode || !videoOK ||
(!rangeOK && !dvStripEligible && !clientDV81Eligible && !clientHDR10Eligible) ||
(subtitle.RequiresBurn && !remuxSubtitleOK && !hlsRemuxSubtitleOK) {
reasonOverride := ""
if !quality.RequiresTranscode && !videoOK && videoEvidenceInsufficient {
// The only reason this route adapts is the evidence tier, not a
// negative device fact; name that in the decision and in any
// resulting terminal.
reasonOverride = EvidenceInsufficientForDirectV3
}
// True when the burn requirement is the sole disjunct that fired: every
// other route condition still permits a source-preserving delivery.
subtitleForcedAdaptation := !quality.RequiresTranscode && videoOK &&
(rangeOK || dvStripEligible || clientDV81Eligible || clientHDR10Eligible) &&
subtitle.RequiresBurn && !remuxSubtitleOK && !hlsRemuxSubtitleOK
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, reasonOverride, subtitleForcedAdaptation)
}
// Profile 7 is normalized on the client against the original range-capable
// source. A decoder profile/max-instance claim alone is not proof of native
// dual-layer output, so the default Android route mirrors Silo Apple: P8.1
// base-layer Dolby Vision first, then same-file HDR10.
if source.DVProfile == 7 && quality.PreservesSource && videoOK && containerOK && audioOK &&
audioSelectionUsesContainerDefaultV3(file, input.AudioTrackIndex) && !subtitle.RequiresBurn {
if clientDV81Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "client_dv7_to_dv81"
plan.EffectiveRecipe.DynamicRange = DynamicRangeDolbyVisionV3
plan.Claims.Video = VideoClaimsV3{DolbyVision: true, DolbyVisionReason: "client_profile7_to_profile81"}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToDV81V3, Executor: ExecutorClientV3, RecipeVersion: ClientDVTransformVersionV3,
ValidatedClaims: []string{"profile7_rpu_converted_to_profile81", "hdr10_base_layer_preserved", "enhancement_layer_discarded"},
})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{
Code: "dolby_vision_enhancement_layer_discarded",
Message: "Dolby Vision Profile 7 is played as Profile 8.1 base-layer Dolby Vision; enhancement-layer pixel data is discarded.",
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(input.Request, DeliveryClassOriginalHTTPV3, plan) && !planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
if clientHDR10Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "client_dv7_to_hdr10"
plan.EffectiveRecipe.DynamicRange = DynamicRangeHDR10V3
plan.Claims.Video = VideoClaimsV3{HDR10: true}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToHDR10V3, Executor: ExecutorClientV3, RecipeVersion: ClientDVTransformVersionV3,
ValidatedClaims: DV7ToHDR10ClaimsV3(),
})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{
Code: "dolby_vision_removed",
Message: "Dolby Vision Profile 7 is played from the same 4K file as its HDR10 base layer.",
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(input.Request, DeliveryClassOriginalHTTPV3, plan) && !planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
}
if source.DVProfile != 7 && deliveryAvailableV3(input.Request, DeliveryClassOriginalHTTPV3) && containerOK && videoOK && rangeOK && audioOK && quality.PreservesSource &&
audioSelectionUsesContainerDefaultV3(file, input.AudioTrackIndex) && !subtitle.RequiresBurn {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "validated_original_playback"
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(input.Request, DeliveryClassOriginalHTTPV3, plan) && !planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
// A progressive remux maps only the base-layer video stream, so dual-layer
// Profile 7 can never ship as native Dolby Vision here regardless of the
// client's decoder claims; the validated HDR10 strip is the only eligible
// P7 remux recipe.
remuxRangeOK := rangeOK && source.DVProfile != 7
// A copy-unsafe source (H.264 with conflicting in-band PPS) must not take a
// video stream-copy route: the avc1/fMP4 segment would desync strict
// decoders. Skipping the remux branch drops through to the HLS transcode.
if videoOK && !source.VideoCopyUnsafe && (remuxRangeOK || dvStripEligible) && (remuxSubtitleOK || hlsRemuxSubtitleOK) {
plan := base
plan.Delivery = DeliveryRemuxProgressiveV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: containerMP4V3, MIMEType: mimeVideoMP4V3, Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "container_normalization"
transcodeAudio := !audioOK
progressiveAudioChannels := 0
localAudioConvertOK := input.Registry != nil && input.Registry.Available(TransformationAudioToAACV3)
if transcodeAudio {
// The HLS remux branch below can offload the conversion to a
// pooled node, but only for clients that can run an HLS
// delivery: a progressive-only client must keep this terminal
// (its retryable semantics included) rather than fall through
// to a generic adaptation_unavailable for a route it can never
// use. Short-circuit order keeps locally-capable planning from
// consulting node capabilities at all.
audioConvertOK := localAudioConvertOK ||
hlsDeliveryOK && input.hlsRegistry().Available(TransformationAudioToAACV3)
if !audioConvertOK {
return terminalPlannerResultV3(TerminalAudioConversionUnsupportedV3, "The required validated AAC conversion toolchain is unavailable.", true)
}
progressiveAudioChannels = aacOutputChannelsV3(input.Request, DeliveryClassProgressiveV3, source.AudioChannels, false)
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(progressiveAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayoutForChannelsV3(progressiveAudioChannels)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "server_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{ClaimAudioDecodeV3}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: fmt.Sprintf("The selected audio track is converted to AAC %s.", audioLayoutForChannelsV3(progressiveAudioChannels))})
plan.DecisionReason = "audio_adaptation"
}
dvStrip := dvStripEligible && (source.DVProfile == 7 || !rangeOK)
if dvStrip {
plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationServerDV7HDR10V3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: DV7ToHDR10ClaimsV3()})
plan.EffectiveRecipe.DynamicRange = DynamicRangeHDR10V3
plan.Claims.Video = VideoClaimsV3{HDR10: true}
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "dolby_vision_removed", Message: "Dolby Vision metadata is removed and the validated HDR10 base layer is preserved."})
}
if !dvStrip {
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
}
// The progressive remux executes on this process's ffmpeg, so its
// server transformations must be locally available; when only pooled
// nodes carry them, the HLS remux below ships the same recipe on a
// node-offloadable delivery instead.
progressiveExecutable := (!transcodeAudio || localAudioConvertOK) && (!dvStrip || dvStripEligibleLocal)
if remuxSubtitleOK && progressiveExecutable {
applySubtitleDecisionV3(&plan, remuxSubtitle.Decision)
plan.Claims.Subtitles = remuxSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(input.Request, DeliveryClassProgressiveV3, plan) && !planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, TargetAudioChannels: progressiveAudioChannels, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec, DownloadedSubtitleID: remuxSubtitle.DownloadedSubtitleID}
}
}
if deliveryAvailableV3(input.Request, DeliveryClassHLSV3) && hlsRemuxSubtitleOK {
plan.AppliedQuirks = []AppliedQuirkV3{}
plan.RuntimeCorrections = []string{}
plan.Delivery = DeliveryRemuxHLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
hlsTranscodeAudio := transcodeAudio
hlsAudioChannels := 0
if hlsTranscodeAudio {
hlsAudioChannels = aacOutputChannelsV3(input.Request, DeliveryClassHLSV3, source.AudioChannels, false)
plan.EffectiveRecipe.AudioChannels = intPointerV3(hlsAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayoutForChannelsV3(hlsAudioChannels)
}
if audioQuirk, ok := hlsEAC3AudioCorrectionV3(source, input.Request); ok && !hlsTranscodeAudio {
if !input.hlsRegistry().Available(TransformationAudioToAACV3) {
return terminalPlannerResultV3(TerminalAudioConversionUnsupportedV3, "The device-specific HLS route requires the validated AAC conversion toolchain.", true)
}
hlsTranscodeAudio = true
hlsAudioChannels = aacOutputChannelsV3(input.Request, DeliveryClassHLSV3, source.AudioChannels, false)
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(hlsAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayoutForChannelsV3(hlsAudioChannels)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "device_hls_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{ClaimAudioDecodeV3}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: fmt.Sprintf("The selected audio track is converted to AAC %s for this device's HLS route.", audioLayoutForChannelsV3(hlsAudioChannels))})
appendAppliedQuirkV3(&plan, *audioQuirk, "")
}
// DTS and TrueHD are not HLS-native audio codecs. A client's
// progressive DTS decode claim does not transfer to segmented
// fMP4/TS: copied DTS in an HLS route drags Media3's audio clock
// (device stall corrections, ~0.3x pacing, frozen position
// reports), so the copy is converted to AAC — surround-preserving
// when the source is multichannel.
if !hlsTranscodeAudio && !hlsNativeAudioCodecV3(source.AudioCodec) {
if !input.hlsRegistry().Available(TransformationAudioToAACV3) {
return terminalPlannerResultV3(TerminalAudioConversionUnsupportedV3, "The HLS route requires the validated AAC conversion toolchain.", true)
}
hlsTranscodeAudio = true
hlsAudioChannels = aacOutputChannelsV3(input.Request, DeliveryClassHLSV3, source.AudioChannels, true)
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(hlsAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayoutForChannelsV3(hlsAudioChannels)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "hls_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{ClaimAudioDecodeV3}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: "The selected audio track is converted to AAC for HLS delivery."})
}
if !dvStrip {
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
}
if hlsTranscodeAudio {
plan.DecisionReason = "hls_audio_adaptation"
} else {
plan.DecisionReason = "hls_packaging_required"
}
applySubtitleDecisionV3(&plan, hlsSubtitle.Decision)
plan.Claims.Subtitles = hlsSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(input.Request, DeliveryClassHLSV3, plan) && !planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
targetAudio := "copy"
if hlsTranscodeAudio {
targetAudio = "aac"
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec, DownloadedSubtitleID: hlsSubtitle.DownloadedSubtitleID}
}
}
}
if deliveryAvailableV3(input.Request, DeliveryClassHLSV3) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "copy_routes_exhausted", false)
}
return terminalPlannerResultV3("adaptation_unavailable", "No validated playback route is available for this source and output route.", false)
}
// availableQualitiesV3 publishes the server ladder rungs a client could
// request for this source through a quality_change replan. The source rung is
// always present; transcode rungs are listed only below the source's own
// height and only when the cheap transcode gates pass. Registry availability
// is deliberately not consulted: it can trigger lazy node-capability fetches,
// which source-preserving starts must never pay for, and a rung whose
// toolchain is missing degrades to a retryable terminal at replan time.
func availableQualitiesV3(input PlannerInputV3, source SourceDescriptorV3) []AvailableQualityV3 {
qualities := []AvailableQualityV3{{
Label: QualityOriginalV3,
Height: source.Height,
BitrateKbps: source.BitrateKbps,
PreservesSource: true,
}}
if source.Height <= 0 {
// Fixed rungs must sit strictly below a known source height; unknown
// probe metadata cannot prove that any advertised rung avoids upscaling.
return qualities
}
if !deliveryAvailableV3(input.Request, DeliveryClassHLSV3) || !input.Settings.TranscodeEnabled {
return qualities
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return qualities
}
if hdrTranscodeUnavailableV3(source) {
return qualities
}
for _, height := range []int{2160, 1080, 720, 480} {
if height >= source.Height {
continue
}
qualities = append(qualities, AvailableQualityV3{
Label: resolutionLabelV3(height),
Height: height,
BitrateKbps: ladderBitrateKbpsV3(height),
})
}
return qualities
}
// audioAvailableQualitiesV3 is the audio-only menu: quality rungs are a video
// concept, so the only entry is the source itself.
func audioAvailableQualitiesV3(source SourceDescriptorV3) []AvailableQualityV3 {
return []AvailableQualityV3{{Label: QualityOriginalV3, BitrateKbps: source.BitrateKbps, PreservesSource: true}}
}
// decisionReasonBandwidthCapV3 marks a plan whose recipe was constrained by
// the request's bandwidth cap rather than by decode capability.
const decisionReasonBandwidthCapV3 = "quality_bandwidth_cap"
// planAudioOnlyV3 plans sources without a video track (audiobooks, music).
// The route family is deliberately small: the original container over
// progressive HTTP when the client decodes the audio codec, otherwise a
// progressive AAC conversion remux. Video, HDR, quality-ladder, and
// subtitle-burn gates do not apply.
func planAudioOnlyV3(input PlannerInputV3, file *models.MediaFile, source SourceDescriptorV3) PlannerResultV3 {
request := input.Request
audioOK, _, audioClaims := audioEligibilityV3(source, request)
bandwidthCapKbps := optionalValueV3(request.BandwidthCapKbps)
bandwidthCapExceeded := bandwidthCapKbps > 0 && source.BitrateKbps > bandwidthCapKbps
if source.AudioCodec == "" {
// A file with neither a video track nor a probed audio codec has no
// stream the planner can validate a route for.
return terminalPlannerResultV3("source_metadata_incomplete", "The source is missing audio metadata required for a validated playback route.", true)
}
base := PlanV3{
ProtocolVersion: ProtocolV3,
ExpiresAt: NewPlanExpiryV3(input.Now),
SelectedTracks: selectedTracksForPlanV3(file, input.AudioTrackIndex, SubtitlePolicyResultV3{SelectedIndex: -1, TransportIndex: -1}),
EffectiveRecipe: recipeFromSourceV3(source),
Claims: ValidationClaimsV3{Audio: audioClaims},
// Audio-only routes bypass every subtitle gate, so the inventory is
// empty rather than a list of tracks no route on this plan can deliver.
Subtitle: SubtitleDecisionV3{Mode: SubtitleOffV3, Inventory: []SubtitleInventoryItemV3{}},
Transformations: []TransformationV3{},
AppliedQuirks: []AppliedQuirkV3{},
RuntimeCorrections: []string{},
AvailableQualities: audioAvailableQualitiesV3(source),
DegradationWarnings: []DegradationWarningV3{},
RequestedMediaFileID: input.RequestedFile.ID,
EffectiveMediaFileID: file.ID,
Source: source,
SubtitleFidelityPolicy: subtitlePolicyNameV3(request.SubtitleFidelityPreference),
Timeline: TimelineV3{SourceStartSeconds: floatOrZeroV3(request.StartPosition), PlayerStartSeconds: floatOrZeroV3(request.StartPosition), CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
containerOK := containsFoldV3(request.Capabilities.Containers, source.Container)
if audioOK && containerOK && !bandwidthCapExceeded && audioSelectionUsesContainerDefaultV3(file, input.AudioTrackIndex) && deliveryAvailableV3(request, DeliveryClassOriginalHTTPV3) {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "validated_original_playback"
finalizePlanIdentityV3(&plan, request.PlaybackAttemptID, request.ClientPlaybackContext.Output.OutputContextID)
if deliverySupportsPlanV3(request, DeliveryClassOriginalHTTPV3, plan) && !planAttemptedV3(plan, request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
}
if !deliveryAvailableV3(request, DeliveryClassProgressiveV3) {
return terminalPlannerResultV3("adaptation_unavailable", "No validated playback route is available for this audio source.", false)
}
transcodeAudio := !audioOK || bandwidthCapExceeded
if transcodeAudio && (input.Registry == nil || !input.Registry.Available(TransformationAudioToAACV3)) {
return terminalPlannerResultV3(TerminalAudioConversionUnsupportedV3, "The required validated AAC conversion toolchain is unavailable.", true)
}
plan := base
plan.Delivery = DeliveryRemuxProgressiveV3
// The remux muxes an audio-only fMP4, so the plan must promise audio/mp4:
// a declared-tier client probes the advertised MIME with isTypeSupported
// before it will attach a source buffer, and "video/mp4" with no video
// track is exactly the mismatch that makes that probe lie.
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: containerMP4V3, MIMEType: AudioOnlyRemuxMIMEV3, Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.DecisionReason = "container_normalization"
targetAudioChannels := audioOnlyAACOutputChannelsV3(request, source)
targetAudioBitrateKbps := 0
if transcodeAudio {
targetAudioBitrateKbps = audioOnlyAACBitrateKbpsV3(bandwidthCapKbps)
applyAudioOnlyAACConversionV3(&plan, targetAudioChannels, targetAudioBitrateKbps, bandwidthCapExceeded)
} else if !deliverySupportsPlanV3(request, DeliveryClassProgressiveV3, plan) && input.Registry != nil && input.Registry.Available(TransformationAudioToAACV3) {
converted := plan
targetAudioBitrateKbps = audioOnlyAACBitrateKbpsV3(bandwidthCapKbps)
applyAudioOnlyAACConversionV3(&converted, targetAudioChannels, targetAudioBitrateKbps, false)
if deliverySupportsPlanV3(request, DeliveryClassProgressiveV3, converted) {
plan = converted
transcodeAudio = true
}
}
if !deliverySupportsPlanV3(request, DeliveryClassProgressiveV3, plan) {
return terminalPlannerResultV3("adaptation_unavailable", "The progressive delivery cannot decode the planned audio recipe.", false)
}
finalizePlanIdentityV3(&plan, request.PlaybackAttemptID, request.ClientPlaybackContext.Output.OutputContextID)
if planAttemptedV3(plan, request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
}
if !transcodeAudio {
targetAudioChannels = 0
targetAudioBitrateKbps = 0
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, TargetAudioChannels: targetAudioChannels, TargetAudioBitrateKbps: targetAudioBitrateKbps, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
func audioOnlyAACOutputChannelsV3(request StartRequestV3, source SourceDescriptorV3) int {
return aacOutputChannelsV3(request, DeliveryClassProgressiveV3, source.AudioChannels, false)
}
// aacOutputChannelsV3 picks an encoder-supported AAC layout that does not
// exceed the active delivery's ceiling. FFmpeg's planned AAC recipes support
// mono, stereo, and 5.1; an intermediate ceiling therefore falls back from
// 5.1 to stereo rather than advertising an output the encoder never creates.
func aacOutputChannelsV3(request StartRequestV3, deliveryClass string, sourceChannels int, preserveSurround bool) int {
channels := 2
if sourceChannels == 1 {
channels = 1
} else if preserveSurround && sourceChannels >= 6 {
channels = 6
}
capability, ok := request.ClientPlaybackContext.Deliveries[deliveryClass]
if !ok || capability.MaxChannels == nil || *capability.MaxChannels <= 0 || channels <= *capability.MaxChannels {
return channels
}
if *capability.MaxChannels == 1 {
return 1
}
return 2
}
func audioLayoutForChannelsV3(channels int) string {
switch channels {
case 1:
return audioLayoutMonoV3
case 6:
return audioLayoutSurround51V3
default:
return audioLayoutStereoV3
}
}
func audioOnlyAACBitrateKbpsV3(bandwidthCapKbps int) int {
const defaultAACBitrateKbps = 192
if bandwidthCapKbps > 0 && bandwidthCapKbps < defaultAACBitrateKbps {
return bandwidthCapKbps
}
return defaultAACBitrateKbps
}
func applyAudioOnlyAACConversionV3(plan *PlanV3, targetChannels, targetBitrateKbps int, bandwidthCapExceeded bool) {
layout := audioLayoutStereoV3
warning := "The selected audio track is converted to AAC stereo."
if targetChannels == 1 {
layout = audioLayoutMonoV3
warning = "The selected audio track is converted to AAC mono."
}
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(targetChannels)
plan.EffectiveRecipe.AudioLayout = layout
plan.EffectiveRecipe.BitrateKbps = intPointerV3(targetBitrateKbps)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "server_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{ClaimAudioDecodeV3}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: warning})
plan.DecisionReason = "audio_adaptation"
if bandwidthCapExceeded {
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "bandwidth_cap_applied", Message: "Delivery quality is limited by the configured bandwidth cap."})
plan.DecisionReason = decisionReasonBandwidthCapV3
}
}
// planVideoTranscodeV3 always executes on the HLS delivery, so the caller must
// pass the subtitle policy resolved against DeliveryClassHLSV3.
//
// subtitleForcedAdaptation marks the case where only the subtitle burn
// requirement forced this adaptation. Deselecting the subtitle then restores
// playback, so a refusal must name the subtitle: an HDR or version reason
// sends the user chasing a problem that is not blocking them. Retryable
// infrastructure failures and the client-route terminal keep their own reasons.
func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescriptorV3, quality QualityResultV3, subtitle SubtitlePolicyResultV3, reasonOverride string, subtitleForcedAdaptation bool) PlannerResultV3 {
if !deliveryAvailableV3(input.Request, DeliveryClassHLSV3) {
return terminalPlannerResultV3("client_hls_unsupported", "The client cannot execute the required HLS adaptation route.", false)
}
if subtitle.Terminal != nil {
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
if !input.Settings.TranscodeEnabled {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but transcoding is unavailable.", false)
}
return terminalPlannerResultV3("transcoding_disabled", "The source requires video adaptation, but transcoding is unavailable.", false)
}
if hdrTranscodeUnavailableV3(source) {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but this HDR source cannot be re-encoded.", false)
}
return terminalPlannerResultV3("hdr_transcode_unsupported", "This HDR source requires video encoding, but no validated HDR-preserving or tone-map recipe is installed.", false)
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but 4K transcoding is disabled.", false)
}
return terminalPlannerResultV3("no_alternate_version", TerminalMessage4KTranscodeDisabledV3, false)
}
if !input.hlsRegistry().Available(TransformationVideoToH264V3) || !input.hlsRegistry().Available(TransformationAudioToAACV3) {
return terminalPlannerResultV3("conversion_tool_unavailable", "The required validated H.264/AAC conversion toolchain is unavailable.", true)
}
plan := base
plan.Delivery = DeliveryTranscodeHLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshNoneV3}
plan.EffectiveRecipe.VideoCodec = "h264"
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.Width = intPointerV3(quality.Width)
plan.EffectiveRecipe.Height = intPointerV3(quality.Height)
plan.EffectiveRecipe.BitrateKbps = intPointerV3(quality.BitrateKbps)
// Surround sources keep 5.1 through the AAC re-encode (universal Media3
// decode); only stereo/mono sources — and unknown layouts — downmix to 2.0.
targetAudioChannels := aacOutputChannelsV3(input.Request, DeliveryClassHLSV3, source.AudioChannels, true)
audioLayout := audioLayoutForChannelsV3(targetAudioChannels)
plan.EffectiveRecipe.AudioChannels = intPointerV3(targetAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayout
plan.Transformations = append(plan.Transformations,
TransformationV3{Name: TransformationVideoToH264V3, Executor: ExecutorServerV3, RecipeVersion: TransformationVideoToH264RecipeVersionV3, ValidatedClaims: []string{ClaimH264DecodeV3}},
TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{ClaimAudioDecodeV3}},
)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Passthrough: false, AtmosPreserved: false, Reason: "server_audio_adaptation"}
applySubtitleDecisionV3(&plan, subtitle.Decision)
plan.Claims.Subtitles = subtitle.Claims
plan.DecisionReason = quality.Reason
if reasonOverride != "" {
plan.DecisionReason = reasonOverride
}
if subtitle.RequiresBurn {
plan.DecisionReason = "subtitle_burn_in_required"
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "subtitle_burn_in", Message: "The selected subtitle is rendered into the video."})
}
plan.EffectiveRecipe.DynamicRange = DynamicRangeSDRV3
plan.Claims.Video = VideoClaimsV3{}
if !deliverySupportsPlanV3(input.Request, DeliveryClassHLSV3, plan) {
return terminalPlannerResultV3("adaptation_unavailable", "The HLS delivery cannot decode the planned transcode recipe.", false)
}
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID, input.Request.ClientPlaybackContext.Output.OutputContextID)
if planAttemptedV3(plan, input.Request.ClientPlaybackContext.Output.OutputContextID, input.AttemptedKeys) {
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
// applySubtitleDecisionV3 changes the delivery-specific subtitle policy without
// discarding the source inventory already frozen onto the base plan. Adapted
// routes still address the same combined ordinal space as original_http; only
// the rendering decision and claims vary by delivery capability.
func applySubtitleDecisionV3(plan *PlanV3, decision SubtitleDecisionV3) {
if plan == nil {
return
}
inventory := plan.Subtitle.Inventory
plan.Subtitle = decision
plan.Subtitle.Inventory = inventory
}
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
if source.DynamicRange != DynamicRangeDolbyVisionV3 || !clientSupportsHDR10V3(request) || registry == nil || !registry.Available(TransformationServerDV7HDR10V3) {
return false
}
// Profile 7 always carries an HDR10-viewable base layer. Profile 8 is
// safe only when the DOVI compatibility id explicitly identifies HDR10.
return source.DVProfile == 7 || source.DVProfile == 8 && source.DVBLCompatID == 1
}
func canClientTransformDV7ToDV81V3(source SourceDescriptorV3, request StartRequestV3) bool {
if source.DynamicRange != DynamicRangeDolbyVisionV3 || source.DVProfile != 7 ||
!clientTransformationAvailableV3(request, ClientDV7ToDV81V3, ClientDVTransformVersionV3) {
return false
}
// The registered conversion recipe produces Profile 8.1.
source.DVBLCompatID = 1
return clientSupportsDVProfileV3(request, source, 8)
}
func canClientTransformDV7ToHDR10V3(source SourceDescriptorV3, request StartRequestV3) bool {
return source.DynamicRange == DynamicRangeDolbyVisionV3 && source.DVProfile == 7 && clientSupportsHDR10V3(request) &&
clientTransformationAvailableV3(request, ClientDV7ToHDR10V3, ClientDVTransformVersionV3)
}
func clientSupportsDVProfileV3(request StartRequestV3, source SourceDescriptorV3, profile int) bool {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
source.DVProfile = profile
return hdr != nil && hdrSupportsDolbyVisionSourceV3(*hdr, source)
}
func clientTransformationAvailableV3(request StartRequestV3, name, version string) bool {
if !HasFeatureV3(request.ClientFeatures, FeatureClientVideoTransforms) {
return false
}
delivery, ok := request.ClientPlaybackContext.Deliveries[DeliveryClassOriginalHTTPV3]
if !ok || !delivery.Enabled || !delivery.SupportedOnDevice {
return false
}
for _, transformation := range delivery.Transformations {
if transformation.Executor == ExecutorClientV3 && transformation.Name == name && transformation.RecipeVersion == version {
return true
}
}
return false
}
func is4KSourceV3(file *models.MediaFile, source SourceDescriptorV3) bool {
resolution := ""
if file != nil {
resolution = strings.ToLower(strings.TrimSpace(file.Resolution))
}
return resolution == "2160p" || resolution == "4k" || resolution == "uhd" || source.Width >= 3840 || source.Height >= 2160
}
type QualityResultV3 struct {
Label string
Width int
Height int
BitrateKbps int
PreservesSource bool
RequiresTranscode bool
// ExplicitRung marks a user-selected fixed rung, as opposed to an
// automatic reduction from device limits, bandwidth evidence, or caps.
ExplicitRung bool
Reason string
Warnings []DegradationWarningV3
}
// ResolveQualityPolicyV3 selects the delivery quality for a plan.
//
// bandwidth_cap_kbps is a hard delivery ceiling and is honored in every
// quality mode: source-preserving delivery is degraded when the source bitrate
// exceeds the cap, fixed rungs are lowered when their ladder bitrate exceeds
// it, and "auto" folds the cap into bandwidth-based rung selection. A metered
// connection with neither a cap nor a bandwidth estimate limits auto
// selection to the conservative 720p rung — the rung auto would pick for a
// mid-range bandwidth estimate — instead of assuming the link can sustain the
// original stream.
func ResolveQualityPolicyV3(request StartRequestV3, source SourceDescriptorV3) QualityResultV3 {
quality, changed := NormalizeQualityV3(request.QualityPreference)
var warnings []DegradationWarningV3
if changed {
warnings = append(warnings, DegradationWarningV3{Code: "quality_preference_normalized", Message: "Unknown quality preference was normalized to auto."})
}
capKbps := optionalValueV3(request.BandwidthCapKbps)
capExceededBySource := capKbps > 0 && source.BitrateKbps > capKbps
if quality == QualityOriginalV3 && !capExceededBySource {
result := originalQualityResultV3(source)
result.Warnings = warnings
return result
}
targetHeight := source.Height
reason := "quality_auto_source"
explicitRung := false
capApplied := false
switch {
case quality == QualityOriginalV3:
// Only reached when the source bitrate exceeds the cap: the cap is a
// hard ceiling and outranks the original preference.
targetHeight = ladderHeightForBandwidthV3(int(float64(capKbps) * 0.8))
capApplied = true
case quality != "auto":
targetHeight, _ = strconv.Atoi(strings.TrimSuffix(quality, "p"))
reason = "quality_fixed_rung"
explicitRung = true
default:
maxHeight := resolutionHeightV3(request.Capabilities.MaxResolution)
if maxHeight > 0 && (targetHeight == 0 || maxHeight < targetHeight) {
targetHeight = maxHeight
reason = "quality_device_limit"
}
bandwidth := optionalValueV3(request.BandwidthEstimateKbps)
if capKbps > 0 && (bandwidth == 0 || capKbps < bandwidth) {
bandwidth = capKbps
}
if bandwidth > 0 {
targetHeight = minPositiveV3(targetHeight, ladderHeightForBandwidthV3(int(float64(bandwidth)*0.8)))
reason = "quality_bandwidth_limit"
} else if request.Metered {
if capped := minPositiveV3(targetHeight, 720); capped != targetHeight {
targetHeight = capped
reason = "quality_metered_limit"
}
}
}
if targetHeight <= 0 {
targetHeight = 1080
}
if source.Height > 0 && targetHeight > source.Height {
targetHeight = source.Height
}
// The cap also constrains the rung chosen above: a rung that would
// preserve the source is forced down when the source bitrate exceeds the
// cap, and a transcode rung whose ladder bitrate exceeds the cap drops to
// the cap's rung.
if capKbps > 0 && !capApplied {
wouldPreserve := source.Height > 0 && targetHeight >= source.Height
if (wouldPreserve && capExceededBySource) || (!wouldPreserve && ladderBitrateKbpsV3(targetHeight) > capKbps) {
capApplied = true
if capHeight := ladderHeightForBandwidthV3(int(float64(capKbps) * 0.8)); capHeight < targetHeight {
targetHeight = capHeight
}
}
}
if capApplied {
reason = decisionReasonBandwidthCapV3
warnings = append(warnings, DegradationWarningV3{Code: "bandwidth_cap_applied", Message: "Delivery quality is limited by the configured bandwidth cap."})
}
if source.Height > 0 && targetHeight >= source.Height && !capApplied {
return QualityResultV3{
Label: strconv.Itoa(source.Height) + "p",
Width: source.Width,
Height: source.Height,
BitrateKbps: source.BitrateKbps,
PreservesSource: true,
ExplicitRung: explicitRung,
Reason: reason,
Warnings: warnings,
}
}
label := resolutionLabelV3(targetHeight)
effectiveHeight := resolutionHeightV3(label)
if source.Height > 0 && effectiveHeight > source.Height {
effectiveHeight = source.Height
label = resolutionLabelV3(effectiveHeight)
}
width, bitrate := qualityDimensionsV3(effectiveHeight, source.Width, source.Height)
if capKbps > 0 && bitrate > capKbps {
// The ladder has no rung below 480p, so a cap under the lowest rung's
// bitrate is honored by lowering the encode target directly: the cap
// is a hard delivery ceiling, never advisory.
bitrate = capKbps
}
result := QualityResultV3{Label: label, Width: width, Height: effectiveHeight, BitrateKbps: bitrate, PreservesSource: !capApplied && source.Height > 0 && effectiveHeight >= source.Height, ExplicitRung: explicitRung, Reason: reason, Warnings: warnings}
result.RequiresTranscode = !result.PreservesSource
return result
}
func originalQualityResultV3(source SourceDescriptorV3) QualityResultV3 {
return QualityResultV3{Label: resolutionLabelV3(source.Height), Width: source.Width, Height: source.Height, BitrateKbps: source.BitrateKbps, PreservesSource: true, Reason: "quality_original"}
}
// hlsNativeAudioCodecV3 reports whether an audio codec can be stream-copied
// into an HLS delivery. The allowlist follows the HLS authoring spec (AAC,
// AC-3, E-AC-3, MP3); everything else — DTS, TrueHD, PCM, Opus — must be
// converted even when the client can decode it in a progressive container.
func hlsNativeAudioCodecV3(codec string) bool {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "aac", "ac3", "eac3", "mp3":
return true
}
return false
}
// hdrTranscodeUnavailableV3 mirrors planVideoTranscodeV3's terminal
// condition: no validated HDR-preserving or tone-map transcode recipe exists.
func hdrTranscodeUnavailableV3(source SourceDescriptorV3) bool {
return source.DynamicRange != "" && source.DynamicRange != DynamicRangeSDRV3
}
// videoTranscodeExecutableV3 mirrors planVideoTranscodeV3's terminal
// preconditions: it reports whether a validated video transcode of this
// source could actually run for this client and configuration.
func videoTranscodeExecutableV3(input PlannerInputV3, source SourceDescriptorV3) bool {
if !deliveryAvailableV3(input.Request, DeliveryClassHLSV3) || !input.Settings.TranscodeEnabled {
return false
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return false
}
if hdrTranscodeUnavailableV3(source) {
return false
}
return input.hlsRegistry().Available(TransformationVideoToH264V3) && input.hlsRegistry().Available(TransformationAudioToAACV3)
}
func recipeFromSourceV3(source SourceDescriptorV3) EffectiveRecipeV3 {
return EffectiveRecipeV3{VideoCodec: source.VideoCodec, AudioCodec: source.AudioCodec, Width: intPointerV3(source.Width), Height: intPointerV3(source.Height), FrameRate: floatPointerV3(source.FrameRate), BitrateKbps: intPointerV3(source.BitrateKbps), DynamicRange: source.DynamicRange, AudioChannels: intPointerV3(source.AudioChannels), AudioLayout: source.AudioLayout}
}
func selectedTracksForPlanV3(file *models.MediaFile, audioIndex int, subtitle SubtitlePolicyResultV3) SelectedTracksV3 {
selected := SelectedTracksV3{}
if file != nil && audioIndex >= 0 && audioIndex < len(file.AudioTracks) {
index := audioIndex
selected.Audio = &TrackIdentityV3{ID: TrackIDV3(file.ID, "audio", audioIndex), Index: &index}
}
if file != nil && subtitle.SelectedIndex >= 0 {
index := subtitle.SelectedIndex
selected.Subtitle = &TrackIdentityV3{ID: TrackIDV3(file.ID, "subtitle", index), Index: &index}
}
return selected
}
// audioSelectionUsesContainerDefaultV3 reports whether an untouched source
// stream can realize the selected audio track. Original HTTP serves the file
// byte-for-byte, so any non-default selection must use a remux/transcode route
// that can map the requested stream explicitly.
func audioSelectionUsesContainerDefaultV3(file *models.MediaFile, audioIndex int) bool {
if file == nil || len(file.AudioTracks) == 0 {
return true
}
defaultIndex := 0
for index, track := range file.AudioTracks {
if track.Default {
defaultIndex = index
break
}
}
if audioIndex < 0 || audioIndex >= len(file.AudioTracks) {
audioIndex = defaultIndex
}
return audioIndex == defaultIndex
}
func finalizePlanIdentityV3(plan *PlanV3, attemptID string, outputContextID string) {
plan.PlanID = DeterministicPlanIDV3(attemptID, plan.RequestedMediaFileID, plan.EffectiveMediaFileID, *plan)
plan.PlanAttemptKey = PlanAttemptKeyV3(*plan, outputContextID, nil)
}
// planAttemptedV3 compares FNV-hex attempt keys exactly after trimming
// whitespace; the keys are case-sensitive hashes, not free-form labels.
func planAttemptedV3(plan PlanV3, outputContextID string, attempted []string) bool {
wanted := PlanAttemptKeyV3(plan, outputContextID, nil)
for _, key := range attempted {
if strings.TrimSpace(key) == wanted {
return true
}
}
return false
}
func terminalPlannerResultV3(reason, message string, retryable bool) PlannerResultV3 {
return PlannerResultV3{Terminal: &TerminalV3{Reason: reason, Message: message, Retryable: retryable}, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
func subtitlePolicyNameV3(f SubtitleFidelityV3) string {
if f == SubtitleFidelityPreserveV3 {
return "require_authored_fidelity"
}
return "allow_simplified_rendering"
}
func floatOrZeroV3(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func intPointerV3(v int) *int {
if v <= 0 {
return nil
}
value := v
return &value
}
func floatPointerV3(v float64) *float64 {
if v <= 0 {
return nil
}
value := v
return &value
}
func optionalValueV3(v *int) int {
if v == nil {
return 0
}
return *v
}
func resolutionHeightV3(v string) int {
value, _ := strconv.Atoi(strings.TrimSuffix(strings.ToLower(v), "p"))
if strings.EqualFold(v, "4k") {
return 2160
}
return value
}
func resolutionLabelV3(h int) string {
switch {
case h >= 2160:
return "2160p"
case h >= 1080:
return "1080p"
case h >= 720:
return "720p"
default:
return "480p"
}
}
func ladderHeightForBandwidthV3(kbps int) int {
switch {
case kbps >= 20_000:
return 2160
case kbps >= 8_000:
return 1080
case kbps >= 4_000:
return 720
default:
return 480
}
}
func minPositiveV3(a, b int) int {
if a <= 0 {
return b
}
if b <= 0 || a < b {
return a
}
return b
}
// ladderBitrateKbpsV3 matches the established web ladder's standard shared
// rungs; 2160p is the v3-only extension until the web menu exposes a 4K
// transcode tier.
func ladderBitrateKbpsV3(height int) int {
bitrates := map[int]int{480: 1_500, 720: 2_000, 1080: 6_000, 2160: 20_000}
return bitrates[resolutionHeightV3(resolutionLabelV3(height))]
}
func qualityDimensionsV3(height, sourceWidth, sourceHeight int) (int, int) {
rung := resolutionHeightV3(resolutionLabelV3(height))
width := 0
if sourceWidth > 0 && sourceHeight > 0 {
width = sourceWidth * rung / sourceHeight
width -= width % 2
}
if width == 0 {
width, _ = dimensionsFromResolutionV3(resolutionLabelV3(rung))
}
return width, ladderBitrateKbpsV3(rung)
}
func SortedTransformationNamesV3(values []TransformationV3) []string {
result := make([]string, 0, len(values))
for _, v := range values {
result = append(result, v.Name)
}
sort.Strings(result)
return result
}
func deliveryAvailableV3(request StartRequestV3, deliveryClass string) bool {
capability, ok := request.ClientPlaybackContext.Deliveries[deliveryClass]
if !ok {
return false
}
return capability.Enabled && capability.SupportedOnDevice
}
// deliverySupportsPlanV3 applies the capability limits scoped to the delivery
// class after a concrete recipe has been built. Empty lists preserve clients
// that only advertise class availability; non-empty lists are authoritative
// subsets of the top-level device capabilities.
func deliverySupportsPlanV3(request StartRequestV3, deliveryClass string, plan PlanV3) bool {
capability, ok := request.ClientPlaybackContext.Deliveries[deliveryClass]
if !ok || !capability.Enabled || !capability.SupportedOnDevice {
return false
}
if len(capability.Containers) > 0 && !containsFoldV3(capability.Containers, plan.Stream.Container) {
return false
}
if codec := strings.TrimSpace(plan.EffectiveRecipe.VideoCodec); codec != "" && len(capability.VideoCodecs) > 0 && !containsFoldV3(capability.VideoCodecs, codec) {
return false
}
if codec := strings.TrimSpace(plan.EffectiveRecipe.AudioCodec); codec != "" {
hasAudioConstraints := len(capability.AudioDecodeCodecs) > 0 || len(capability.AudioPassthroughCodecs) > 0
if hasAudioConstraints {
supportedCodecs := capability.AudioDecodeCodecs
if plan.Claims.Audio.Passthrough {
supportedCodecs = capability.AudioPassthroughCodecs
}
if !containsFoldV3(supportedCodecs, codec) {
return false
}
}
}
if capability.MaxChannels != nil && plan.EffectiveRecipe.AudioChannels != nil && *plan.EffectiveRecipe.AudioChannels > *capability.MaxChannels {
return false
}
if capability.HDRDetails != nil && !hdrDetailsSupportPlanV3(*capability.HDRDetails, plan) {
return false
}
return true
}
func hdrDetailsSupportPlanV3(hdr HDRCapabilitiesV3, plan PlanV3) bool {
switch plan.EffectiveRecipe.DynamicRange {
case "", DynamicRangeSDRV3:
return true
case DynamicRangeHDR10V3, "hdr_unknown":
return hdr.HDR10 && hdr10LimitsSupportPlanV3(hdr, plan)
case DynamicRangeHDR10PlusV3:
return hdr.HDR10Plus
case DynamicRangeHLGV3:
return hdr.HLG
case DynamicRangeDolbyVisionV3:
candidate := plan.Source
for _, transformation := range plan.Transformations {
if transformation.Name == ClientDV7ToDV81V3 {
candidate.DVProfile = 8
candidate.DVBLCompatID = 1
break
}
}
return hdrSupportsDolbyVisionSourceV3(hdr, candidate)
default:
return false
}
}
func hdr10LimitsSupportPlanV3(hdr HDRCapabilitiesV3, plan PlanV3) bool {
return !(hdr.HDR10MaxWidth > 0 && (plan.EffectiveRecipe.Width == nil || *plan.EffectiveRecipe.Width > hdr.HDR10MaxWidth) ||
hdr.HDR10MaxHeight > 0 && (plan.EffectiveRecipe.Height == nil || *plan.EffectiveRecipe.Height > hdr.HDR10MaxHeight) ||
hdr.HDR10MaxFrameRate > 0 && (plan.EffectiveRecipe.FrameRate == nil || *plan.EffectiveRecipe.FrameRate > hdr.HDR10MaxFrameRate) ||
hdr.HDR10MaxBitrateKbps > 0 && (plan.EffectiveRecipe.BitrateKbps == nil || *plan.EffectiveRecipe.BitrateKbps > hdr.HDR10MaxBitrateKbps))
}
func hdrSupportsDolbyVisionSourceV3(hdr HDRCapabilitiesV3, source SourceDescriptorV3) bool {
if !containsIntV3(hdr.DolbyVisionProfiles, source.DVProfile) {
return false
}
var matchedCapability DolbyVisionProfileCapabilityV3
foundCapability := false
for _, capability := range hdr.DolbyVisionProfileLevels {
if capability.Profile == source.DVProfile {
matchedCapability = capability
foundCapability = true
break
}
}
if !foundCapability {
// Existing clients predate level-bounded Dolby Vision claims. Once a
// client sends any bounds, every advertised profile must have one.
return len(hdr.DolbyVisionProfileLevels) == 0
}
if len(matchedCapability.BLCompatibilityIDs) > 0 &&
!containsIntV3(matchedCapability.BLCompatibilityIDs, source.DVBLCompatID) {
return false
}
if source.DVLevel > 0 {
return source.DVLevel <= matchedCapability.MaxLevel
}
return dolbyVisionSourceFitsLevelV3(source, matchedCapability.MaxLevel)
}
func dolbyVisionSourceFitsLevelV3(source SourceDescriptorV3, maxLevel int) bool {
// Dolby Vision Version 2.0 defines levels by maximum pixel rate, decoded
// width, and high-tier bitrate. Use those physical bounds for legacy rows
// scanned before Silo persisted ffprobe's exact dv_level.
type levelLimit struct {
pixelRate float64
width int
bitrateKbps int
}
limits := map[int]levelLimit{
1: {22_118_400, 1280, 50_000}, 2: {27_648_000, 1280, 50_000},
3: {49_766_400, 1920, 70_000}, 4: {62_208_000, 2560, 70_000},
5: {124_416_000, 3840, 70_000}, 6: {199_065_600, 3840, 130_000},
7: {248_832_000, 3840, 130_000}, 8: {398_131_200, 3840, 130_000},
9: {497_664_000, 3840, 130_000}, 10: {995_328_000, 3840, 240_000},
11: {995_328_000, 7680, 240_000}, 12: {1_990_656_000, 7680, 480_000},
13: {3_981_312_000, 7680, 800_000},
}
limit, ok := limits[maxLevel]
if !ok || source.Width <= 0 || source.Height <= 0 || source.FrameRate <= 0 || source.BitrateKbps <= 0 {
return false
}
return source.Width <= limit.width &&
float64(source.Width*source.Height)*source.FrameRate <= limit.pixelRate &&
source.BitrateKbps <= limit.bitrateKbps
}
func ExplainPlannerResultV3(result PlannerResultV3) string {
if result.Plan != nil {
return fmt.Sprintf("%s:%s", result.Plan.Delivery, result.Plan.DecisionReason)
}
if result.Terminal != nil {
return "terminal:" + result.Terminal.Reason
}
return "invalid"
}