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.
This commit is contained in:
+1
-1
@@ -929,7 +929,7 @@ func main() {
|
||||
s.SetLiteraryWorkLinker(literaryWorkService)
|
||||
s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB))
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,16 @@ type VideoTrack struct {
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
PixelFormat string `json:"pixel_format,omitempty"`
|
||||
ReferenceFrames int `json:"reference_frames,omitempty"`
|
||||
// MultiplePPS records whether an H.264 stream redefines the same
|
||||
// pic_parameter_set_id in-band with more than one distinct content. Such
|
||||
// streams cannot be safely stream-copied into an avc1/fMP4 HLS segment:
|
||||
// the avcC declares a single parameter set, so strict decoders
|
||||
// (VideoToolbox on Safari/Chrome-macOS) desync on the mid-GOP switches.
|
||||
//
|
||||
// This is a runtime-only field: it is computed at playback start by a
|
||||
// bitstream scan and held in memory, never serialized to the database
|
||||
// (`json:"-"`). nil means "not analyzed in this process yet".
|
||||
MultiplePPS *bool `json:"-"`
|
||||
}
|
||||
|
||||
// AudioTrack represents a probed audio stream stored as JSONB.
|
||||
|
||||
@@ -50,6 +50,7 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
|
||||
source.HDR10Plus = track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+")
|
||||
source.DVProfile = track.DVProfile
|
||||
source.DVBLCompatID = track.DVBLCompatID
|
||||
source.VideoCopyUnsafe = videoCopyUnsafeFile(file)
|
||||
switch EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer)) {
|
||||
case EnhancementNoneV3, EnhancementMELV3, EnhancementFELV3, EnhancementUnknownV3:
|
||||
source.DVEnhancementLayer = EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer))
|
||||
|
||||
@@ -298,7 +298,10 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
// client's decoder claims; the validated HDR10 strip is the only eligible
|
||||
// P7 remux recipe.
|
||||
remuxRangeOK := rangeOK && source.DVProfile != 7
|
||||
if videoOK && (remuxRangeOK || dvStripEligible) && (remuxSubtitleOK || hlsRemuxSubtitleOK) {
|
||||
// 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
|
||||
|
||||
@@ -460,6 +460,10 @@ type SourceDescriptorV3 struct {
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
AudioChannels int `json:"audio_channels,omitempty"`
|
||||
AudioLayout string `json:"audio_layout,omitempty"`
|
||||
// VideoCopyUnsafe marks a source whose video stream cannot be safely
|
||||
// stream-copied into an avc1/fMP4 segment (H.264 with conflicting in-band
|
||||
// PPS). Copy/remux routes are disqualified for it; a real encode is used.
|
||||
VideoCopyUnsafe bool `json:"video_copy_unsafe,omitempty"`
|
||||
}
|
||||
|
||||
type VideoClaimsV3 struct {
|
||||
|
||||
@@ -496,6 +496,48 @@ func TestPlanPlaybackV3AudioAdaptationCopiesVideo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// copyUnsafeFixtureV3 returns an SDR source that would normally take a
|
||||
// video-copy remux (its audio needs conversion, its container is not offered),
|
||||
// with the copy-safety flag settable by the caller.
|
||||
func copyUnsafeFixtureV3(multiPPS bool) (*models.MediaFile, StartRequestV3) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.VideoTracks[0].VideoRange = "SDR"
|
||||
file.VideoTracks[0].VideoRangeType = "SDR"
|
||||
file.VideoTracks[0].ColorTransfer = "bt709"
|
||||
file.AudioTracks[0] = models.AudioTrack{Codec: "truehd", Channels: 8, Layout: "7.1"}
|
||||
file.CodecAudio = "truehd"
|
||||
file.VideoTracks[0].MultiplePPS = &multiPPS
|
||||
req := validStartRequestV3()
|
||||
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
|
||||
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
|
||||
req.Capabilities.Containers = []string{"mp4"}
|
||||
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
|
||||
return file, req
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3CopyUnsafeSourceForcesTranscode(t *testing.T) {
|
||||
// The source carries conflicting in-band PPS, so the video stream-copy remux
|
||||
// is disqualified and planning must fall through to a real transcode.
|
||||
file, req := copyUnsafeFixtureV3(true)
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
|
||||
if result.PlayMethod != PlayTranscode {
|
||||
t.Fatalf("PlayMethod = %q, want transcode; result = %s", result.PlayMethod, ExplainPlannerResultV3(result))
|
||||
}
|
||||
if result.Plan == nil || result.Plan.DecisionReason != "copy_routes_exhausted" {
|
||||
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3CopySafeSourceStillCopies(t *testing.T) {
|
||||
// The identical source with the copy-safety scan resolved to safe keeps the
|
||||
// cheap video stream-copy remux.
|
||||
file, req := copyUnsafeFixtureV3(false)
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
|
||||
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxProgressiveV3 || !result.TranscodeAudio || result.TargetVideoCodec != "" {
|
||||
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3FallsBackFromProgressiveToHLSWithoutRepeatingKey(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.VideoTracks[0].VideoRange = "SDR"
|
||||
|
||||
@@ -94,8 +94,15 @@ func Resolve(file *models.MediaFile, caps ClientCapabilities, settings AdminSett
|
||||
}
|
||||
}
|
||||
|
||||
// A copy-unsafe source (H.264 with conflicting in-band PPS) cannot take a
|
||||
// video stream-copy route: the remux would desync strict decoders. Force it
|
||||
// past the remux cases into a full video transcode. Direct play of the
|
||||
// original file (Case 1) stays available — decoders that reparse in-band
|
||||
// parameter sets handle the original container fine.
|
||||
copyUnsafe := videoCopyUnsafeFile(file)
|
||||
|
||||
// Case 2: Client supports codecs but not container → remux.
|
||||
if videoOK && audioOK && !containerOK {
|
||||
if videoOK && audioOK && !containerOK && !copyUnsafe {
|
||||
return &PlayDecision{
|
||||
Method: PlayRemux,
|
||||
File: file,
|
||||
@@ -105,7 +112,7 @@ func Resolve(file *models.MediaFile, caps ClientCapabilities, settings AdminSett
|
||||
|
||||
// Case 3: Video OK but audio codec unsupported → remux with audio transcode.
|
||||
// This is much cheaper than a full video transcode.
|
||||
if videoOK && !audioOK {
|
||||
if videoOK && !audioOK && !copyUnsafe {
|
||||
return &PlayDecision{
|
||||
Method: PlayRemux,
|
||||
File: file,
|
||||
@@ -259,3 +266,14 @@ func is4K(res string) bool {
|
||||
func containsStr(slice []string, s string) bool {
|
||||
return slices.Contains(slice, s)
|
||||
}
|
||||
|
||||
// videoCopyUnsafeFile reports whether the file's video stream cannot be safely
|
||||
// stream-copied into an avc1/fMP4 segment. It is set once by the multi-PPS
|
||||
// bitstream scan (H.264 sources that redefine a pic_parameter_set_id in-band
|
||||
// with conflicting content). nil/false means copy is safe or not yet analyzed.
|
||||
func videoCopyUnsafeFile(file *models.MediaFile) bool {
|
||||
if file == nil || len(file.VideoTracks) == 0 {
|
||||
return false
|
||||
}
|
||||
return file.VideoTracks[0].MultiplePPS != nil && *file.VideoTracks[0].MultiplePPS
|
||||
}
|
||||
|
||||
@@ -65,6 +65,38 @@ func TestResolver_RemuxWithAudioTranscode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_CopyUnsafeForcesTranscode(t *testing.T) {
|
||||
unsafe := true
|
||||
// h264+dts in mkv would normally remux with audio transcode (video copied),
|
||||
// but the source carries conflicting in-band PPS, so the video copy is unsafe
|
||||
// and it must fall through to a full transcode.
|
||||
file := &models.MediaFile{
|
||||
CodecVideo: "h264", CodecAudio: "dts", Container: "mkv",
|
||||
Resolution: "1080p", HDR: false,
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264", MultiplePPS: &unsafe}},
|
||||
}
|
||||
decision := playback.Resolve(file, defaultCaps(), defaultSettings())
|
||||
|
||||
if decision.Method != playback.PlayTranscode {
|
||||
t.Errorf("method = %q, want transcode", decision.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_CopySafeStillRemuxes(t *testing.T) {
|
||||
safe := false
|
||||
// The same shape with the copy-safety scan resolved to safe keeps remuxing.
|
||||
file := &models.MediaFile{
|
||||
CodecVideo: "h264", CodecAudio: "dts", Container: "mkv",
|
||||
Resolution: "1080p", HDR: false,
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264", MultiplePPS: &safe}},
|
||||
}
|
||||
decision := playback.Resolve(file, defaultCaps(), defaultSettings())
|
||||
|
||||
if decision.Method != playback.PlayRemux {
|
||||
t.Errorf("method = %q, want remux", decision.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AudioPassthroughSkipsAudioTranscode(t *testing.T) {
|
||||
// Source is h264 + eac3 in mp4. Client can decode h264 but not eac3; its
|
||||
// sink advertises eac3 passthrough (e.g. HDMI AVR). Should direct-play
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// copySafetyScanSeconds bounds how much of the stream the multi-PPS scan
|
||||
// demuxes. Affected encoders emit every PPS variant within the opening GOPs
|
||||
// (all four in the reference file appear inside the first two seconds); a
|
||||
// generous window catches slower rotations while staying a stream-copy, so the
|
||||
// scan finishes in well under a second regardless of runtime.
|
||||
const copySafetyScanSeconds = 15
|
||||
|
||||
// DetectMultiplePPSH264 reports whether an H.264 stream redefines the same
|
||||
// pic_parameter_set_id in-band with more than one distinct content within the
|
||||
// opening copySafetyScanSeconds. Such streams are unsafe to stream-copy into an
|
||||
// avc1/fMP4 HLS segment because the avcC advertises a single parameter set.
|
||||
//
|
||||
// It stream-copies (no decode) the leading window, extracts the raw PPS NAL
|
||||
// units with ffmpeg's filter_units bitstream filter, and groups them by
|
||||
// pic_parameter_set_id. A legal stream that uses several distinct PPS ids
|
||||
// (0, 1, 2 …) is not flagged — only a single id carrying conflicting
|
||||
// definitions is.
|
||||
func DetectMultiplePPSH264(ctx context.Context, ffmpegPath, filePath string) (bool, error) {
|
||||
if strings.TrimSpace(ffmpegPath) == "" {
|
||||
return false, fmt.Errorf("ffmpeg path not configured")
|
||||
}
|
||||
// -bsf:v filter_units=pass_types=8 keeps only PPS NAL units (type 8); the
|
||||
// Annex-B h264 muxer emits them start-code delimited on stdout.
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-v", "error",
|
||||
"-t", fmt.Sprintf("%d", copySafetyScanSeconds),
|
||||
"-i", filePath,
|
||||
"-map", "0:v:0",
|
||||
"-c:v", "copy",
|
||||
"-bsf:v", "filter_units=pass_types=8",
|
||||
"-f", "h264",
|
||||
"-",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false, fmt.Errorf("pps scan: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
return annexBHasConflictingPPS(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// annexBHasConflictingPPS reports whether the Annex-B PPS stream redefines any
|
||||
// single pic_parameter_set_id with more than one distinct payload. A stream
|
||||
// that uses several distinct ids (each with one definition) is legal and not
|
||||
// flagged; only conflicting redefinitions of the same id are unsafe.
|
||||
func annexBHasConflictingPPS(data []byte) bool {
|
||||
byID := make(map[uint]map[string]struct{})
|
||||
for _, nal := range splitAnnexBNALs(data) {
|
||||
if len(nal) < 2 || nal[0]&0x1f != 8 {
|
||||
continue // not a PPS NAL
|
||||
}
|
||||
id, ok := ppsParameterSetID(nal[1:])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if byID[id] == nil {
|
||||
byID[id] = make(map[string]struct{})
|
||||
}
|
||||
byID[id][string(nal)] = struct{}{}
|
||||
if len(byID[id]) > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitAnnexBNALs splits an Annex-B byte stream into NAL unit payloads,
|
||||
// dropping the 3- or 4-byte start codes.
|
||||
func splitAnnexBNALs(data []byte) [][]byte {
|
||||
var nals [][]byte
|
||||
n := len(data)
|
||||
for start := nextStartCode(data, 0); start >= 0; {
|
||||
// skip the 3-byte start code (00 00 01); a leading extra 00 stays with
|
||||
// the previous NAL's trailing bytes, harmless for delimiting.
|
||||
payloadStart := start + 3
|
||||
next := nextStartCode(data, payloadStart)
|
||||
end := n
|
||||
if next >= 0 {
|
||||
end = next
|
||||
}
|
||||
// Trim trailing zero bytes: the 4th byte of a 00 00 00 01 start code and
|
||||
// any trailing_zero_bits/cabac_zero_word belong to the delimiter, not the
|
||||
// NAL. Without this, an identical PPS before a 4-byte start code and one
|
||||
// at end-of-stream compare unequal.
|
||||
for end > payloadStart && data[end-1] == 0x00 {
|
||||
end--
|
||||
}
|
||||
if payloadStart < end {
|
||||
nals = append(nals, data[payloadStart:end])
|
||||
}
|
||||
start = next
|
||||
}
|
||||
return nals
|
||||
}
|
||||
|
||||
// nextStartCode returns the index of the 00 00 01 sequence at or after from,
|
||||
// pointing at the first 00 (a leading 00 00 00 01 start code resolves to the
|
||||
// three trailing bytes, which is sufficient for delimiting). Returns -1 when
|
||||
// none remains.
|
||||
func nextStartCode(data []byte, from int) int {
|
||||
for i := from; i+2 < len(data); i++ {
|
||||
if data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x01 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ppsParameterSetID decodes pic_parameter_set_id, the first ue(v) element of a
|
||||
// PPS RBSP. It is the leading field, so no emulation-prevention byte can occur
|
||||
// before it and the raw payload can be read directly. Returns false when the
|
||||
// payload is too short or malformed.
|
||||
func ppsParameterSetID(rbsp []byte) (uint, bool) {
|
||||
br := bitReader{data: rbsp}
|
||||
return br.readUE()
|
||||
}
|
||||
|
||||
type bitReader struct {
|
||||
data []byte
|
||||
pos int // bit position
|
||||
}
|
||||
|
||||
func (b *bitReader) readBit() (uint, bool) {
|
||||
if b.pos/8 >= len(b.data) {
|
||||
return 0, false
|
||||
}
|
||||
bit := (b.data[b.pos/8] >> (7 - uint(b.pos%8))) & 1
|
||||
b.pos++
|
||||
return uint(bit), true
|
||||
}
|
||||
|
||||
// readUE decodes an unsigned Exp-Golomb value.
|
||||
func (b *bitReader) readUE() (uint, bool) {
|
||||
zeros := 0
|
||||
for {
|
||||
bit, ok := b.readBit()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if bit == 1 {
|
||||
break
|
||||
}
|
||||
zeros++
|
||||
if zeros > 31 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
val := uint(0)
|
||||
for i := 0; i < zeros; i++ {
|
||||
bit, ok := b.readBit()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
val = (val << 1) | bit
|
||||
}
|
||||
return (1 << uint(zeros)) - 1 + val, true
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package scanner
|
||||
|
||||
import "testing"
|
||||
|
||||
// annexB wraps NAL payloads with 4-byte start codes.
|
||||
func annexB(nals ...[]byte) []byte {
|
||||
var out []byte
|
||||
for _, n := range nals {
|
||||
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||
out = append(out, n...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pps builds a PPS NAL (header 0x68) from the given RBSP payload bytes.
|
||||
func pps(rbsp ...byte) []byte {
|
||||
return append([]byte{0x68}, rbsp...)
|
||||
}
|
||||
|
||||
func TestAnnexBHasConflictingPPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "single pps repeated is safe",
|
||||
// 0xee -> pic_parameter_set_id=0
|
||||
data: annexB(pps(0xee, 0x35, 0x25), pps(0xee, 0x35, 0x25), pps(0xee, 0x35, 0x25)),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "same id redefined with different content is unsafe",
|
||||
// both decode to pic_parameter_set_id=0 (leading bit set) but differ.
|
||||
data: annexB(pps(0xee, 0x35, 0x25), pps(0xe9, 0x23, 0x52, 0x50)),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "distinct ids each defined once is safe",
|
||||
// 0xe8 -> id 0 ; 0x48 -> id 1 ; 0x28 -> id 4
|
||||
data: annexB(pps(0xe8), pps(0x48), pps(0x28)),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty stream is safe",
|
||||
data: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-pps nal ignored",
|
||||
// 0x65 = IDR slice (type 5), not a PPS.
|
||||
data: annexB([]byte{0x65, 0x88, 0x84}, pps(0xee, 0x35)),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := annexBHasConflictingPPS(tt.data); got != tt.want {
|
||||
t.Fatalf("annexBHasConflictingPPS = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPPSParameterSetID(t *testing.T) {
|
||||
// pic_parameter_set_id is the first ue(v) of the RBSP.
|
||||
cases := map[byte]uint{
|
||||
0xe8: 0, // 1.... -> 0
|
||||
0x48: 1, // 010.. -> 1
|
||||
0x68: 2, // 011.. -> 2
|
||||
0x20: 3, // 00100 -> 3
|
||||
0x28: 4, // 00101 -> 4
|
||||
}
|
||||
for first, want := range cases {
|
||||
if got, ok := ppsParameterSetID([]byte{first, 0x00}); !ok || got != want {
|
||||
t.Errorf("ppsParameterSetID(0x%02x) = %d (ok=%v), want %d", first, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package scanner
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -78,43 +79,122 @@ func videoTracksMissingColorRange(tracks []models.VideoTrack) bool {
|
||||
type PlaybackProbeEnsurer struct {
|
||||
fileRepo *FileRepository
|
||||
ffprobePath string
|
||||
ffmpegPath string
|
||||
timeout time.Duration
|
||||
// copySafety memoizes the multi-PPS bitstream scan per file for the life of
|
||||
// the process. It is never persisted: the scan runs on the first playback
|
||||
// after a restart and is recomputed lazily thereafter.
|
||||
copySafety sync.Map // file ID -> copySafetyResult
|
||||
}
|
||||
|
||||
func NewPlaybackProbeEnsurer(fileRepo *FileRepository, ffprobePath string, timeout time.Duration) *PlaybackProbeEnsurer {
|
||||
type copySafetyResult struct {
|
||||
size int64
|
||||
multi bool
|
||||
}
|
||||
|
||||
func NewPlaybackProbeEnsurer(fileRepo *FileRepository, ffprobePath, ffmpegPath string, timeout time.Duration) *PlaybackProbeEnsurer {
|
||||
return &PlaybackProbeEnsurer{
|
||||
fileRepo: fileRepo,
|
||||
ffprobePath: ffprobePath,
|
||||
ffmpegPath: ffmpegPath,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *PlaybackProbeEnsurer) Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) {
|
||||
if file == nil || !NeedsCriticalProbeRepair(file) {
|
||||
if file == nil || e == nil || e.fileRepo == nil {
|
||||
return file, nil
|
||||
}
|
||||
if e == nil || e.fileRepo == nil || strings.TrimSpace(e.ffprobePath) == "" {
|
||||
|
||||
current := file
|
||||
if NeedsCriticalProbeRepair(file) && strings.TrimSpace(e.ffprobePath) != "" {
|
||||
timeout := e.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if reprobeMayScanPackets(file) && timeout < time.Minute {
|
||||
timeout = time.Minute
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
probe, err := ProbeFile(probeCtx, e.ffprobePath, file.FilePath)
|
||||
cancel()
|
||||
if err != nil || probe == nil {
|
||||
return file, err
|
||||
}
|
||||
updated := *file
|
||||
applyProbeData(&updated, probe, "local")
|
||||
repaired, err := e.fileRepo.Upsert(ctx, updated)
|
||||
if err != nil {
|
||||
return file, err
|
||||
}
|
||||
current = repaired
|
||||
}
|
||||
|
||||
// Copy-safety analysis is independent of critical probe repair: an
|
||||
// already-probed file still needs its one-time multi-PPS scan before the
|
||||
// planner can decide whether a video stream-copy is safe.
|
||||
return e.ensureCopySafety(ctx, current)
|
||||
}
|
||||
|
||||
// ensureCopySafety computes the multi-PPS copy-safety flag for H.264 files at
|
||||
// playback start and stamps it on an in-memory copy of the file. The result is
|
||||
// memoized per process and never written to the database, so it is recomputed
|
||||
// on the first play after a restart.
|
||||
func (e *PlaybackProbeEnsurer) ensureCopySafety(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) {
|
||||
if !needsCopySafetyProbe(file) || strings.TrimSpace(e.ffmpegPath) == "" {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
if cached, ok := e.copySafety.Load(file.ID); ok {
|
||||
if result, ok := cached.(copySafetyResult); ok && result.size == file.FileSize {
|
||||
return fileWithMultiplePPS(file, result.multi), nil
|
||||
}
|
||||
}
|
||||
|
||||
timeout := e.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
if timeout < 30*time.Second {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if reprobeMayScanPackets(file) && timeout < time.Minute {
|
||||
timeout = time.Minute
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
probe, err := ProbeFile(probeCtx, e.ffprobePath, file.FilePath)
|
||||
if err != nil || probe == nil {
|
||||
scanCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
multi, err := DetectMultiplePPSH264(scanCtx, e.ffmpegPath, file.FilePath)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// Leave the flag unset so a transient failure retries on the next play
|
||||
// rather than caching a wrong answer; the copy path stays available.
|
||||
return file, err
|
||||
}
|
||||
|
||||
e.copySafety.Store(file.ID, copySafetyResult{size: file.FileSize, multi: multi})
|
||||
return fileWithMultiplePPS(file, multi), nil
|
||||
}
|
||||
|
||||
// fileWithMultiplePPS returns a shallow copy of file with the (runtime-only)
|
||||
// MultiplePPS flag set on its first video track, without mutating the caller's
|
||||
// file or its shared VideoTracks slice.
|
||||
func fileWithMultiplePPS(file *models.MediaFile, multi bool) *models.MediaFile {
|
||||
updated := *file
|
||||
applyProbeData(&updated, probe, "local")
|
||||
return e.fileRepo.Upsert(ctx, updated)
|
||||
tracks := make([]models.VideoTrack, len(file.VideoTracks))
|
||||
copy(tracks, file.VideoTracks)
|
||||
value := multi
|
||||
tracks[0].MultiplePPS = &value
|
||||
updated.VideoTracks = tracks
|
||||
return &updated
|
||||
}
|
||||
|
||||
// needsCopySafetyProbe reports whether the file is an H.264 video whose
|
||||
// multi-PPS copy-safety flag has not yet been computed.
|
||||
func needsCopySafetyProbe(file *models.MediaFile) bool {
|
||||
if file == nil || len(file.VideoTracks) == 0 {
|
||||
return false
|
||||
}
|
||||
if file.VideoTracks[0].MultiplePPS != nil {
|
||||
return false
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(file.VideoTracks[0].Codec))
|
||||
if codec == "" {
|
||||
codec = strings.ToLower(strings.TrimSpace(file.CodecVideo))
|
||||
}
|
||||
return codec == "h264" || codec == "avc" || codec == "avc1"
|
||||
}
|
||||
|
||||
// reprobeMayScanPackets reports whether reprobing this file is likely to hit
|
||||
|
||||
Reference in New Issue
Block a user