Files
silo-server/internal/scanner/pps.go

172 lines
5.2 KiB
Go

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{})
}
// nal_ref_idc belongs to the NAL header, not the PPS definition. Two
// otherwise-identical PPS payloads may carry different valid priorities.
byID[id][string(nal[1:])] = 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
}