* feat(playback): plan v3 routes from pooled node capabilities Protocol v3 planning previously gated every server transformation on the API host's local ffmpeg probe, so deployments whose toolchain lives on transcode nodes (libx264/aac/dovi_rpu on nodes, minimal binary locally) received conversion terminals before transport preparation ever consulted the selected node's capabilities. Planning now draws on two registries split by executor pool: - Registry stays the local probe and keeps gating progressive remux routes, which execute in this process and can never offload. - HLSRegistry widens availability for HLS deliveries with the pooled transcode nodes' advertised transformations (name and recipe version pinned to the local specs), fetched concurrently under a short planning deadline through the existing TTL cache. Failures are now negatively cached so an unreachable node costs one timeout per window rather than one per start. The remux family picks the executor per branch: a recipe needing transformations only nodes carry skips the progressive remux and ships the same recipe on the HLS remux delivery instead. The local-fallback path in prepareTransportV3 now validates plans against the local registry's advertised set — mirroring the per-node validation — and returns the existing retryable transcode_node_capability_unavailable terminal when no executor can run the recipe, instead of spawning an ffmpeg that would fail at runtime. Deferred from PR #398 review (comment 3579105380). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden union capability planning from review Addresses all four review findings on the capability-union feature: - Select capability-matching nodes: plans carrying server transformations now restrict node selection to nodes whose advertised capabilities validate against the plan (nodepool.PlanSessionWith with a set-lookup predicate), so heterogeneous pools cannot load-balance a recipe onto a node that would reject it while a capable sibling exists. Transformation-free plans keep pure load-based selection. - Split the capability cache by consumer: planning honors negatively cached fetch failures (one timeout per window), while the transport path fetches through them — a memoized 3s planning deadline must not reject an already-selected node that the 10s transport budget could still validate. - Gate node-widened availability on the HLS engine: a progressive-only client that needs audio conversion keeps its specific retryable audio_conversion_unsupported terminal instead of falling through to a non-retryable adaptation_unavailable for routes it can never run; the DV strip union flag is gated identically. - Make HLSRegistry a lazy, memoized producer: the planner only builds the widened registry when a route decision depends on node capabilities, so direct-play and other source-preserving starts never wait on node capability fetches (or their dead-node deadlines). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
122 lines
4.6 KiB
Go
122 lines
4.6 KiB
Go
package playback
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type TransformationSpecV3 struct {
|
|
Name string
|
|
RecipeVersion string
|
|
Available bool
|
|
RequiredCapability string
|
|
PromisedDynamicRange string
|
|
ValidatedClaims []string
|
|
TerminalReason string
|
|
}
|
|
|
|
type TransformationRegistryV3 struct {
|
|
entries map[string]TransformationSpecV3
|
|
}
|
|
|
|
func ProbeTransformationRegistryV3(ctx context.Context, ffmpegPath string) *TransformationRegistryV3 {
|
|
// Resolve exactly like the execution paths (remux and transcode) so every
|
|
// capability advertised here holds for the binary that later runs.
|
|
ffmpegPath = ResolveFFmpegPath(ffmpegPath)
|
|
bsfCtx, cancelBSF := context.WithTimeout(ctx, 3*time.Second)
|
|
bsfs, _ := exec.CommandContext(bsfCtx, ffmpegPath, "-hide_banner", "-bsfs").Output()
|
|
cancelBSF()
|
|
encoderCtx, cancelEncoders := context.WithTimeout(ctx, 3*time.Second)
|
|
encoders, _ := exec.CommandContext(encoderCtx, ffmpegPath, "-hide_banner", "-encoders").Output()
|
|
cancelEncoders()
|
|
_, ffmpegErr := exec.LookPath(ffmpegPath)
|
|
return NewTransformationRegistryV3([]TransformationSpecV3{
|
|
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: bytes.Contains(bsfs, []byte("dovi_rpu")), RequiredCapability: "ffmpeg_bsf:dovi_rpu", PromisedDynamicRange: "hdr10", ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"}, TerminalReason: "dv_conversion_unsupported"},
|
|
{Name: "audio_to_aac", RecipeVersion: "1", Available: ffmpegErr == nil && bytes.Contains(encoders, []byte(" aac ")), RequiredCapability: "ffmpeg_encoder:aac", ValidatedClaims: []string{"media3_audio_decode"}, TerminalReason: "audio_conversion_unsupported"},
|
|
{Name: "video_to_h264", RecipeVersion: "1", Available: ffmpegErr == nil && h264EncoderAvailableV3(encoders), RequiredCapability: "ffmpeg_encoder:h264", PromisedDynamicRange: "sdr", ValidatedClaims: []string{"media3_h264_decode"}, TerminalReason: "video_conversion_unsupported"},
|
|
})
|
|
}
|
|
|
|
// h264EncodersV3 lists every H.264 encoder the transcode pipeline can select
|
|
// (see buildTranscodeArgs' hardware ladder in transcode.go); any one of them
|
|
// satisfies the video_to_h264 transformation.
|
|
var h264EncodersV3 = []string{"libx264", "h264_qsv", "h264_vaapi", "h264_nvenc", "h264_videotoolbox"}
|
|
|
|
func h264EncoderAvailableV3(encoders []byte) bool {
|
|
for _, encoder := range h264EncodersV3 {
|
|
if bytes.Contains(encoders, []byte(encoder)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func NewTransformationRegistryV3(specs []TransformationSpecV3) *TransformationRegistryV3 {
|
|
r := &TransformationRegistryV3{entries: make(map[string]TransformationSpecV3, len(specs))}
|
|
for _, spec := range specs {
|
|
if spec.Name != "" {
|
|
r.entries[spec.Name] = spec
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *TransformationRegistryV3) Available(name string) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
spec, ok := r.entries[name]
|
|
return ok && spec.Available
|
|
}
|
|
|
|
// WithAdvertised returns a registry whose known specs are additionally marked
|
|
// available when a pooled transcode node advertises the same server-executed
|
|
// transformation at the same recipe version. Advertisements never introduce
|
|
// new specs: the planner only selects transformations this server defines,
|
|
// and pinning versions to the local spec guarantees a plan built from the
|
|
// widened registry passes the per-node advertisement validation at transport
|
|
// time. Returns the receiver unchanged when nothing new becomes available.
|
|
func (r *TransformationRegistryV3) WithAdvertised(advertised []TransformationV3) *TransformationRegistryV3 {
|
|
if r == nil || len(advertised) == 0 {
|
|
return r
|
|
}
|
|
specs := make([]TransformationSpecV3, 0, len(r.entries))
|
|
changed := false
|
|
for _, spec := range r.entries {
|
|
if !spec.Available {
|
|
for _, remote := range advertised {
|
|
if strings.EqualFold(strings.TrimSpace(remote.Name), spec.Name) &&
|
|
strings.TrimSpace(remote.RecipeVersion) == spec.RecipeVersion &&
|
|
strings.EqualFold(strings.TrimSpace(remote.Executor), "server") {
|
|
spec.Available = true
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
specs = append(specs, spec)
|
|
}
|
|
if !changed {
|
|
return r
|
|
}
|
|
return NewTransformationRegistryV3(specs)
|
|
}
|
|
|
|
func (r *TransformationRegistryV3) Advertised() []TransformationV3 {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
result := make([]TransformationV3, 0, len(r.entries))
|
|
for _, spec := range r.entries {
|
|
if spec.Available {
|
|
result = append(result, TransformationV3{Name: spec.Name, Executor: "server", RecipeVersion: spec.RecipeVersion, ValidatedClaims: append([]string(nil), spec.ValidatedClaims...)})
|
|
}
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
return result
|
|
}
|