Files
silo-server/internal/playback/plan_v3.go
T
CoffeeKnyteandQuick 9281ae61d9 fix(playback): transcode H.264 sources with conflicting in-band PPS
H.264 streams that redefine the same pic_parameter_set_id in-band with
different content cannot be safely stream-copied into an avc1/fMP4 HLS
segment: the avcC advertises a single parameter set, so VideoToolbox
(Safari/Chrome on macOS) decodes with the wrong PPS and desyncs mid-GOP,
surfacing as PIPELINE_ERROR_DECODE / kVTVideoDecoderBadDataErr (-12909).

At playback start, a bitstream scan (DetectMultiplePPSH264) runs for H.264
files on the probe-ensure path, grouping in-band PPS by id and flagging any
id carrying more than one distinct definition. The result is a runtime-only
flag (VideoTrack.MultiplePPS, json:"-") memoized per process — never written
to the database, no schema change, recomputed on the first play after a
restart.

The v3 planner and legacy resolver disqualify a copy-unsafe source from the
video stream-copy / remux ladder, routing it to a real transcode. Direct
play of the original container is left intact: decoders that reparse in-band
parameter sets (ExoPlayer, VLC, native) handle the source fine.

Part of #135.
2026-07-29 09:49:34 -04:00

866 lines
41 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
}
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
}
// 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
TargetResolution string
TargetBitrateKbps int
SubtitleTrackIndex int
SubtitleTransportTrackIndex int
SubtitleBurnIn bool
SubtitleCodec string
}
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)
// Subtitle renderability is engine-specific, so every candidate route is
// validated against the capabilities of the engine that would execute it.
// The direct engine remains the canonical policy for source-preserving
// routes and for the up-front terminal decision.
subtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3DirectV3, input.AdditionalSubtitles)
if subtitle.Terminal != nil {
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
remuxSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3ProgressiveRemuxV3, input.AdditionalSubtitles)
hlsSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3HLSV3, input.AdditionalSubtitles)
// A remux route cannot burn subtitles, so it is only viable when its own
// engine can deliver the selected subtitle without one.
remuxSubtitleOK := remuxSubtitle.Terminal == nil && !remuxSubtitle.RequiresBurn
hlsRemuxSubtitleOK := hlsSubtitle.Terminal == nil && !hlsSubtitle.RequiresBurn
quality := ResolveQualityPolicyV3(input.Request, source)
videoOK := detailedVideoEligibleV3(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)
hlsEngineOK := engineAvailableV3(input.Request, EngineMedia3HLSV3)
// 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 && hlsEngineOK && source.DynamicRange == "dolby_vision" {
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("dv_conversion_unsupported",
"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.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 !detailedVideoEvidenceCompleteV3(source) {
return terminalPlannerResultV3("source_metadata_incomplete", "The source is missing video metadata required for a validated playback route.", true)
}
// 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) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "")
}
// 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 && !subtitle.RequiresBurn {
if clientDV81Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "client_dv7_to_dv81"
plan.EffectiveRecipe.DynamicRange = "dolby_vision"
plan.Claims.Video = VideoClaimsV3{DolbyVision: true, DolbyVisionReason: "client_profile7_to_profile81"}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToDV81V3, Executor: "client", 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)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
if clientHDR10Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "client_dv7_to_hdr10"
plan.EffectiveRecipe.DynamicRange = "hdr10"
plan.Claims.Video = VideoClaimsV3{HDR10: true}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToHDR10V3, Executor: "client", RecipeVersion: ClientDVTransformVersionV3,
ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"},
})
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)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
}
if source.DVProfile != 7 && engineAvailableV3(input.Request, EngineMedia3DirectV3) && containerOK && videoOK && rangeOK && audioOK && quality.PreservesSource && !subtitle.RequiresBurn {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "validated_original_playback"
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
// 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.Engine = EngineMedia3ProgressiveRemuxV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "container_normalization"
transcodeAudio := !audioOK
localAudioConvertOK := input.Registry != nil && input.Registry.Available("audio_to_aac")
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 ||
hlsEngineOK && input.hlsRegistry().Available("audio_to_aac")
if !audioConvertOK {
return terminalPlannerResultV3("audio_conversion_unsupported", "The required validated AAC conversion toolchain is unavailable.", true)
}
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(2)
plan.EffectiveRecipe.AudioLayout = "stereo"
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "server_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "audio_converted", Message: "The selected audio track is converted to AAC stereo."})
plan.DecisionReason = "audio_adaptation"
}
dvStrip := dvStripEligible && (source.DVProfile == 7 || !rangeOK)
if dvStrip {
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"}})
plan.EffectiveRecipe.DynamicRange = "hdr10"
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 {
plan.Subtitle = remuxSubtitle.Decision
plan.Claims.Subtitles = remuxSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec}
}
}
if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK {
plan.AppliedQuirks = []AppliedQuirkV3{}
plan.RuntimeCorrections = []string{}
plan.Delivery = DeliveryRemuxHLSV3
plan.Engine = EngineMedia3HLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
hlsTranscodeAudio := transcodeAudio
if audioQuirk, ok := hlsEAC3AudioCorrectionV3(source, input.Request); ok && !hlsTranscodeAudio {
if !input.hlsRegistry().Available("audio_to_aac") {
return terminalPlannerResultV3("audio_conversion_unsupported", "The device-specific HLS route requires the validated AAC conversion toolchain.", true)
}
hlsTranscodeAudio = true
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(2)
plan.EffectiveRecipe.AudioLayout = "stereo"
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "device_hls_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "audio_converted", Message: "The selected audio track is converted to AAC stereo for this device's HLS route."})
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.
hlsAudioChannels := 0
if !hlsTranscodeAudio && !hlsNativeAudioCodecV3(source.AudioCodec) {
if !input.hlsRegistry().Available("audio_to_aac") {
return terminalPlannerResultV3("audio_conversion_unsupported", "The HLS route requires the validated AAC conversion toolchain.", true)
}
hlsTranscodeAudio = true
hlsAudioChannels = 2
audioLayout := "stereo"
if source.AudioChannels >= 6 {
hlsAudioChannels = 6
audioLayout = "5.1"
}
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(hlsAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayout
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "hls_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "audio_converted", 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"
}
plan.Subtitle = hlsSubtitle.Decision
plan.Claims.Subtitles = hlsSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, 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}
}
}
}
if engineAvailableV3(input.Request, EngineMedia3HLSV3) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "copy_routes_exhausted")
}
return terminalPlannerResultV3("adaptation_unavailable", "No validated playback route is available for this source and output route.", false)
}
// planVideoTranscodeV3 always executes on the HLS engine, so the caller must
// pass the subtitle policy resolved against EngineMedia3HLSV3.
func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescriptorV3, quality QualityResultV3, subtitle SubtitlePolicyResultV3, reasonOverride string) PlannerResultV3 {
if !engineAvailableV3(input.Request, EngineMedia3HLSV3) {
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 {
reason := "transcoding_disabled"
if subtitle.RequiresBurn {
reason = "subtitle_conversion_unsupported"
}
return terminalPlannerResultV3(reason, "The source requires video adaptation, but transcoding is unavailable.", false)
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return terminalPlannerResultV3("no_alternate_version", "A lower-resolution source is required because 4K transcoding is disabled.", false)
}
if hdrTranscodeUnavailableV3(source) {
return terminalPlannerResultV3("hdr_transcode_unsupported", "This HDR source requires video encoding, but no validated HDR-preserving or tone-map recipe is installed.", false)
}
if !input.hlsRegistry().Available("video_to_h264") || !input.hlsRegistry().Available("audio_to_aac") {
return terminalPlannerResultV3("conversion_tool_unavailable", "The required validated H.264/AAC conversion toolchain is unavailable.", true)
}
plan := base
plan.Delivery = DeliveryTranscodeHLSV3
plan.Engine = EngineMedia3HLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
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 := 2
audioLayout := "stereo"
if source.AudioChannels >= 6 {
targetAudioChannels = 6
audioLayout = "5.1"
}
plan.EffectiveRecipe.AudioChannels = intPointerV3(targetAudioChannels)
plan.EffectiveRecipe.AudioLayout = audioLayout
plan.Transformations = append(plan.Transformations,
TransformationV3{Name: "video_to_h264", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_h264_decode"}},
TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}},
)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Passthrough: false, AtmosPreserved: false, Reason: "server_audio_adaptation"}
plan.Subtitle = 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 = "sdr"
plan.Claims.Video = VideoClaimsV3{}
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if planAttemptedV3(plan, input.Request.OutputRouteGeneration, 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}
}
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
if source.DynamicRange != "dolby_vision" || !clientSupportsHDR10V3(request) || registry == nil || !registry.Available("server_dv7_to_hdr10") {
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 {
return source.DynamicRange == "dolby_vision" && source.DVProfile == 7 &&
clientSupportsDVProfileV3(request, 8) &&
clientTransformationAvailableV3(request, ClientDV7ToDV81V3, ClientDVTransformVersionV3)
}
func canClientTransformDV7ToHDR10V3(source SourceDescriptorV3, request StartRequestV3) bool {
return source.DynamicRange == "dolby_vision" && source.DVProfile == 7 && clientSupportsHDR10V3(request) &&
clientTransformationAvailableV3(request, ClientDV7ToHDR10V3, ClientDVTransformVersionV3)
}
func clientSupportsDVProfileV3(request StartRequestV3, profile int) bool {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
return hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, profile)
}
func clientTransformationAvailableV3(request StartRequestV3, name, version string) bool {
if !HasFeatureV3(request.ClientFeatures, FeatureClientVideoTransforms) &&
!HasFeatureV3(request.ClientPlaybackContext.Features, FeatureClientVideoTransforms) {
return false
}
engine, ok := request.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
if !ok || !engine.Enabled || !engine.SupportedOnDevice {
return false
}
for _, transformation := range engine.Transformations {
if transformation.Executor == "client" && 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: "original" 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 == "original" && !capExceededBySource {
result := originalQualityResultV3(source)
result.Warnings = warnings
return result
}
targetHeight := source.Height
reason := "quality_auto_source"
explicitRung := false
capApplied := false
switch {
case quality == "original":
// 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 = "quality_bandwidth_cap"
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 != "sdr"
}
// 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 !engineAvailableV3(input.Request, EngineMedia3HLSV3) || !input.Settings.TranscodeEnabled {
return false
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return false
}
if hdrTranscodeUnavailableV3(source) {
return false
}
return input.hlsRegistry().Available("video_to_h264") && input.hlsRegistry().Available("audio_to_aac")
}
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
}
func finalizePlanIdentityV3(plan *PlanV3, attemptID string) {
plan.PlanID = DeterministicPlanIDV3(attemptID, plan.RequestedMediaFileID, plan.EffectiveMediaFileID, *plan)
}
// planAttemptedV3 compares FNV-hex attempt keys exactly after trimming
// whitespace; the keys are case-sensitive hashes, not free-form labels.
func planAttemptedV3(plan PlanV3, generation int64, attempted []string) bool {
wanted := PlanAttemptKeyV3(plan, generation, 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 engineAvailableV3(request StartRequestV3, engine EngineV3) bool {
capability, ok := request.ClientPlaybackContext.Engines[string(engine)]
if !ok {
return false
}
return capability.Enabled && capability.SupportedOnDevice
}
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"
}