From 9281ae61d9c6fbc16963aab09cc6b4f381b95c8d Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:26:08 +0000 Subject: [PATCH] fix(playback): transcode H.264 sources with conflicting in-band PPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/silo/main.go | 2 +- internal/models/media.go | 10 ++ internal/playback/capabilities_v3.go | 1 + internal/playback/plan_v3.go | 5 +- internal/playback/protocol_v3.go | 4 + internal/playback/protocol_v3_test.go | 42 +++++++ internal/playback/resolver.go | 22 +++- internal/playback/resolver_test.go | 32 +++++ internal/scanner/pps.go | 169 ++++++++++++++++++++++++++ internal/scanner/pps_test.go | 79 ++++++++++++ internal/scanner/probe_repair.go | 110 ++++++++++++++--- 11 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 internal/scanner/pps.go create mode 100644 internal/scanner/pps_test.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index bc631fe5..e195db60 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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") } diff --git a/internal/models/media.go b/internal/models/media.go index 2b994c15..d71a2c81 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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. diff --git a/internal/playback/capabilities_v3.go b/internal/playback/capabilities_v3.go index 1ee767d9..4f99b129 100644 --- a/internal/playback/capabilities_v3.go +++ b/internal/playback/capabilities_v3.go @@ -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)) diff --git a/internal/playback/plan_v3.go b/internal/playback/plan_v3.go index ef25b7e4..af9282ff 100644 --- a/internal/playback/plan_v3.go +++ b/internal/playback/plan_v3.go @@ -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 diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 9d761c4d..2cc8c277 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -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 { diff --git a/internal/playback/protocol_v3_test.go b/internal/playback/protocol_v3_test.go index 1970b607..bf25ed91 100644 --- a/internal/playback/protocol_v3_test.go +++ b/internal/playback/protocol_v3_test.go @@ -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" diff --git a/internal/playback/resolver.go b/internal/playback/resolver.go index 1c19cd12..92f6a970 100644 --- a/internal/playback/resolver.go +++ b/internal/playback/resolver.go @@ -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 +} diff --git a/internal/playback/resolver_test.go b/internal/playback/resolver_test.go index 34e74752..7353432a 100644 --- a/internal/playback/resolver_test.go +++ b/internal/playback/resolver_test.go @@ -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 diff --git a/internal/scanner/pps.go b/internal/scanner/pps.go new file mode 100644 index 00000000..66dd023f --- /dev/null +++ b/internal/scanner/pps.go @@ -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 +} diff --git a/internal/scanner/pps_test.go b/internal/scanner/pps_test.go new file mode 100644 index 00000000..60fcc7d5 --- /dev/null +++ b/internal/scanner/pps_test.go @@ -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) + } + } +} diff --git a/internal/scanner/probe_repair.go b/internal/scanner/probe_repair.go index 7f0349be..f0ef6448 100644 --- a/internal/scanner/probe_repair.go +++ b/internal/scanner/probe_repair.go @@ -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