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:
CoffeeKnyte
2026-07-29 09:49:34 -04:00
committed by Quick
parent f894b150e1
commit 9281ae61d9
11 changed files with 457 additions and 19 deletions
+1 -1
View File
@@ -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")
}
+10
View File
@@ -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.
+1
View File
@@ -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))
+4 -1
View File
@@ -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
+4
View File
@@ -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 {
+42
View File
@@ -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"
+20 -2
View File
@@ -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
}
+32
View File
@@ -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
+169
View File
@@ -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
}
+79
View File
@@ -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)
}
}
}
+95 -15
View File
@@ -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