Skip to content

fix(playback): transcode H.264 sources with conflicting in-band PPS#424

Open
CoffeeKnyte wants to merge 1 commit into
mainfrom
fix/hls-copy-multi-pps-transcode
Open

fix(playback): transcode H.264 sources with conflicting in-band PPS#424
CoffeeKnyte wants to merge 1 commit into
mainfrom
fix/hls-copy-multi-pps-transcode

Conversation

@CoffeeKnyte

@CoffeeKnyte CoffeeKnyte commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Problem

Some H.264 movies fail to play in the web player with a hard decode error.
A subset of 1080p H.264 library titles, when played through the browser player on
macOS (both Safari and Chrome, which decode video in hardware via VideoToolbox),
throw a decode error a second or two into playback and never recover:

Playback error: PipelineStatus::PIPELINE_ERROR_DECODE:
Error Domain=NSOSStatusErrorDomain Code=-12909 "(null)" (-12909): VTDecompressionOutputCallback

From the operator's side the session starts normally, the player re-requests the
first couple of segments (the browser retrying after a fatal media error), and
then the session is torn down. The exact same titles play correctly everywhere
else — the mobile and TV native apps, VLC, and Firefox — so nothing looks wrong
with the file itself, and it plays end-to-end when fully decoded offline. The
breakage is specific to the browser + hardware-decode path.

Solution

Root cause. The affected titles are H.264 streams that carry their encoding
parameter sets in-band, redefining the same parameter-set id repeatedly
with different contents across the opening frames (the per-frame reference
count changes). When the server stream-copies such a stream into an avc1/fMP4
HLS segment, the segment's single declared parameter set (avcC) disagrees with
the per-frame ones. A strict decoder that honors avc1 — VideoToolbox — configures
once from the declared set and desyncs when the stream switches mid-GOP, which is
exactly the -12909 decode failure. Decoders that re-parse in-band parameter sets
per frame (ExoPlayer, VLC, Firefox's software path) are unaffected, which is why
only the browser hardware-decode surface breaks.

Making the container honest by tagging the copy avc3 (which advertises in-band
parameter sets) was evaluated and does not fix it: VideoToolbox only
reconfigures its decode session at IDR boundaries, and these streams switch
mid-GOP, so the honest tag changes nothing the decoder acts on. The reliable fix
is to stop stream-copying these sources and give the client a freshly encoded,
single-parameter-set stream instead.

Detection. DetectMultiplePPSH264 (internal/scanner/pps.go) runs at
playback start for H.264 sources: it stream-copies the opening seconds through
ffmpeg's filter_units bitstream filter, groups the in-band parameter sets by
id, and flags any id that carries more than one distinct definition. A source
that legitimately uses several distinct ids (each defined once) is not
flagged — only conflicting redefinitions of a single id are. The result is a
runtime-only flag (models.VideoTrack.MultiplePPS, tagged json:"-") memoized
per process in PlaybackProbeEnsurer (internal/scanner/probe_repair.go). It is
never written to the database — no schema change, no migration — and is
recomputed on the first play after a restart (~0.2s, a stream-copy demux, run
once per file per process).

Routing. The v3 planner (internal/playback/plan_v3.go) and the legacy
resolver (internal/playback/resolver.go) disqualify a copy-unsafe source from
the video stream-copy / remux ladder, so it falls through to a real transcode
(planVideoTranscodeV3 / PlayTranscode). Direct play of the original container
is deliberately left intact: clients that reparse in-band parameter sets handle
the untouched source fine and should not be forced to transcode. The flag reaches
the planner through the existing source descriptor (SourceDescriptorV3.VideoCopyUnsafe
in internal/playback/capabilities_v3.go), so the planner stays pure.

Risk / follow-ups

  • Affected sources now transcode for every client instead of stream-copying.
    That is more server load for that class of file, but it is the correct trade
    for reliability — the copy is simply not decodable on the browser path.
  • Detection is H.264-only. HEVC uses a different parameter-set model and is out of
    scope here.
  • The flag is runtime-only by design: it is recomputed on the first play after a
    restart rather than persisted. If the per-play warm-up cost ever matters, the
    same detector result could be persisted on the file's probe metadata later.
  • Direct progressive-MP4 playback of an affected source in a browser is not gated
    (the web player uses HLS, so this path is not exercised in practice); it remains
    a theoretical residual.

Verification

  • go build ./..., go vet ./internal/playback/... ./internal/scanner/..., and
    go test ./internal/playback/... ./internal/scanner/... all pass.
  • New unit tests cover the bitstream analysis: a single repeated parameter set and
    several distinct ids are treated as safe, while the same id redefined with
    conflicting content is flagged (internal/scanner/pps_test.go), plus the
    Exp-Golomb id parser.
  • New planner and resolver tests assert a copy-unsafe source routes to a transcode
    while an otherwise-identical copy-safe source still remuxes
    (internal/playback/protocol_v3_test.go, internal/playback/resolver_test.go).
  • Manually reproduced the -12909 decode error on an affected H.264 remux through
    macOS Chrome, confirmed the detector flags the source, and verified the resulting
    session runs a real encode instead of a video stream-copy.

AI-use disclosure

Implemented with Claude Code.

Summary by CodeRabbit

  • Bug Fixes
    • Improved playback reliability for H.264 videos with conflicting parameter sets.
    • Unsafe video-copy paths now automatically fall back to full transcoding, preventing potential playback desynchronization.
    • Safe sources continue using efficient remuxing where supported.
  • Performance
    • Copy-safety analysis results are reused during runtime to avoid repeated scans.
  • Tests
    • Added coverage for safe and unsafe playback decisions and H.264 parameter-set detection.

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.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds H.264 PPS conflict scanning, stores the result as runtime media metadata, wires FFmpeg into playback probing, and prevents unsafe video stream-copy routes in the resolver and Protocol v3 planner.

Changes

H.264 copy-safety playback

Layer / File(s) Summary
PPS conflict detection
internal/scanner/pps.go, internal/scanner/pps_test.go
FFmpeg extracts PPS NAL units for H.264 files; Annex-B parsing and Exp-Golomb decoding identify conflicting definitions, with parser coverage added.
Probe orchestration and metadata
internal/models/media.go, internal/scanner/probe_repair.go, cmd/silo/main.go
PlaybackProbeEnsurer receives the FFmpeg path, scans eligible files with cached results, and returns runtime MultiplePPS metadata without mutating the original file.
Resolver copy-safety routing
internal/playback/resolver.go, internal/playback/resolver_test.go
Resolver remux decisions now require copy-safe video; tests cover both transcode and remux outcomes.
Protocol v3 descriptor and planning
internal/playback/protocol_v3.go, internal/playback/capabilities_v3.go, internal/playback/plan_v3.go, internal/playback/protocol_v3_test.go
Protocol v3 carries VideoCopyUnsafe, excludes unsafe sources from progressive remux, and tests safe and unsafe planning paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlaybackProbeEnsurer
  participant DetectMultiplePPSH264
  participant MediaFile
  participant SourceDescriptorV3
  participant PlanPlaybackV3
  PlaybackProbeEnsurer->>DetectMultiplePPSH264: scan eligible H.264 file
  DetectMultiplePPSH264-->>PlaybackProbeEnsurer: conflicting PPS result
  PlaybackProbeEnsurer->>MediaFile: set runtime MultiplePPS metadata
  MediaFile->>SourceDescriptorV3: derive VideoCopyUnsafe
  SourceDescriptorV3->>PlanPlaybackV3: provide copy-safety flag
  PlanPlaybackV3->>PlanPlaybackV3: select remux or transcode
Loading

Possibly related PRs

Suggested labels: v1

Suggested reviewers: quick104

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: forcing transcode for H.264 sources with conflicting in-band PPS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/hls-copy-multi-pps-transcode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the v1 Silo v1 scope - auto-adds to the Silo v1 project label Jul 17, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/scanner/pps.go`:
- Line 71: Update the PPS comparison logic around byID in the scanner to key
entries by PPS payload rather than the complete NAL unit, excluding the first
header byte containing nal_ref_idc. Ensure identical payloads with different
valid header priority values are treated as non-conflicting and preserve
conflict detection for differing payloads.

In `@internal/scanner/probe_repair.go`:
- Around line 149-152: Update the probe-repair error path around the visible
return so scan failures are represented as copy-unsafe for the current
resolution while remaining uncached for retry. Introduce or reuse a distinct
runtime state for “scan unknown/failed” rather than setting MultiplePPS, and
ensure the resolver and both callers preserve this conservative result instead
of falling back to video stream-copy when the ensured file is discarded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a919df4f-9613-4cad-a286-673c3150d48f

📥 Commits

Reviewing files that changed from the base of the PR and between b96e359 and 4d2b2f7.

📒 Files selected for processing (11)
  • cmd/silo/main.go
  • internal/models/media.go
  • internal/playback/capabilities_v3.go
  • internal/playback/plan_v3.go
  • internal/playback/protocol_v3.go
  • internal/playback/protocol_v3_test.go
  • internal/playback/resolver.go
  • internal/playback/resolver_test.go
  • internal/scanner/pps.go
  • internal/scanner/pps_test.go
  • internal/scanner/probe_repair.go

Comment thread internal/scanner/pps.go
if byID[id] == nil {
byID[id] = make(map[string]struct{})
}
byID[id][string(nal)] = struct{}{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare the PPS payload, not the complete NAL unit.

The first byte contains nal_ref_idc; two NALs with identical PPS content but different valid header priority values are currently treated as conflicting, unnecessarily forcing transcoding.

Proposed fix and regression case
-		byID[id][string(nal)] = struct{}{}
+		byID[id][string(nal[1:])] = struct{}{}
{
	name: "identical payload with different nal_ref_idc is safe",
	data: annexB(
		append([]byte{0x68}, 0xee, 0x35),
		append([]byte{0x48}, 0xee, 0x35),
	),
	want: false,
},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
byID[id][string(nal)] = struct{}{}
byID[id][string(nal[1:])] = struct{}{}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scanner/pps.go` at line 71, Update the PPS comparison logic around
byID in the scanner to key entries by PPS payload rather than the complete NAL
unit, excluding the first header byte containing nal_ref_idc. Ensure identical
payloads with different valid header priority values are treated as
non-conflicting and preserve conflict detection for differing payloads.

Comment on lines +149 to 152
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not fail open to video stream-copy when the safety scan fails.

Both supplied callers discard the ensured file on error, while the resolver treats an unset flag as safe. Consequently, cancellation, malformed input, or an FFmpeg failure sends an affected source through the exact remux path this change is intended to prevent.

Represent “scan failed/unknown” as conservatively copy-unsafe while leaving it uncached for retry; this likely needs a distinct runtime flag rather than setting MultiplePPS=true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scanner/probe_repair.go` around lines 149 - 152, Update the
probe-repair error path around the visible return so scan failures are
represented as copy-unsafe for the current resolution while remaining uncached
for retry. Introduce or reuse a distinct runtime state for “scan unknown/failed”
rather than setting MultiplePPS, and ensure the resolver and both callers
preserve this conservative result instead of falling back to video stream-copy
when the ensured file is discarded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Silo v1 scope - auto-adds to the Silo v1 project

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant