2026-07-14 11:51:27 -04:00
|
|
|
package playback
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"sort"
|
2026-07-14 12:13:16 -04:00
|
|
|
"strings"
|
2026-07-14 11:51:27 -04:00
|
|
|
"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{
|
2026-08-10 18:14:49 -04:00
|
|
|
{Name: TransformationServerDV7HDR10V3, RecipeVersion: "1", Available: bytes.Contains(bsfs, []byte("dovi_rpu")), RequiredCapability: "ffmpeg_bsf:dovi_rpu", PromisedDynamicRange: DynamicRangeHDR10V3, ValidatedClaims: DV7ToHDR10ClaimsV3(), TerminalReason: TerminalDVConversionUnsupportedV3},
|
|
|
|
|
{Name: TransformationAudioToAACV3, RecipeVersion: "1", Available: ffmpegErr == nil && bytes.Contains(encoders, []byte(" aac ")), RequiredCapability: "ffmpeg_encoder:aac", ValidatedClaims: []string{ClaimAudioDecodeV3}, TerminalReason: TerminalAudioConversionUnsupportedV3},
|
|
|
|
|
{Name: TransformationVideoToH264V3, RecipeVersion: TransformationVideoToH264RecipeVersionV3, Available: ffmpegErr == nil && h264EncoderAvailableV3(encoders), RequiredCapability: "ffmpeg_encoder:h264", PromisedDynamicRange: DynamicRangeSDRV3, ValidatedClaims: []string{ClaimH264DecodeV3}, TerminalReason: TerminalVideoConversionUnsupportedV3},
|
2026-07-14 11:51:27 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 12:13:16 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
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 {
|
2026-08-10 18:14:49 -04:00
|
|
|
result = append(result, TransformationV3{Name: spec.Name, Executor: ExecutorServerV3, RecipeVersion: spec.RecipeVersion, ValidatedClaims: append([]string(nil), spec.ValidatedClaims...)})
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
|
|
|
return result
|
|
|
|
|
}
|