Skip to content

VC-1 (software path) playback judder due to missing frame PTS on I/P pictures #407

Description

@classicjazz

What happened?

VC-1 Advanced Profile content played through AetherEngine's software decode path judders, reproducibly, on Apple TV 4K (via Sodalite/Jellyfin or Moonfin/Jellyfin) and does not judder in mpv or other FFmpeg-based players on the same files on AppleTV. The interlace/deinterlace subsystem, container transport, and file bitrate have all been ruled out. The most likely cause is that AetherEngine's software decoder reads AVFrame.pts directly rather than FFmpeg's best_effort_timestamp, and AVFrame.pts is unset (AV_NOPTS_VALUE) on every I and P frame in affected files — only B-frames carry a valid raw pts. AVSampleBufferDisplayLayer, which the software path uses for presentation, has no independent pacing/cadence model, so it presents strictly on whatever CMTime it's given — any inaccurate fallback timestamp for the 60% of frames lacking raw PTS becomes visible, periodic judder.

Steps to reproduce

Play a VC-1 file in Sodalite
It will exhibit significant (unwatchable) video judder

AetherEngine version or commit SHA

as embedded in Sodalite 1.0.0 (20)

Host app

Sodalite

Platform

tvOS

OS version

tvos 26

Device / chip

Apple TV 4k 3g

Playback path

Software (dav1d) host

Source media (for playback bugs)

vc1 (Advanced) (WVC1 / 0x31435657), yuv420p(progressive), 1920x1080, 23.98 fps, 23.98 tbr, 1k tbn, Matroska container, TimecodeScale default (ms granularity)

ffprobe.txt

Error codes / log lines


Anything else

From Claude based on extensive analysis of video files, logs, and testing across devices and media types. I also have separate analysis of why this is an issue with AetherEngine and not mpv and other ffmpeg based media players.


Environment

  • Repo: github.com/superuser404notfound/AetherEngine, HEAD examined 4219e140 (2026-08-22), release tag 6.34.1
  • Clients: Sodalite and AetherPlayer both pin f4bebc0fd3c66f656ef18d2c7c0b3243df3ac86d = tag 6.34.1 (current release, not stale)
  • Playback target: Apple TV 4K
  • Reproduces via: Jellyfin/Sodalite over 1 Gbps, and direct SMB mount over 10 Gbps — identical judder both ways
  • Sample file: 1920x1080 VC-1 (Advanced), progressive, 23.98 fps, ~19.7 Mb/s, Matroska container, B-frames present

What's been ruled out

  • Profile-conditional interlace assumption. No code path branches on VC-1 profile; VideoRoutingPolicy.requiresSoftwarePath routes all VC-1 to software unconditionally regardless of field order (VideoRoutingPolicy.swift:29-47), and routesSoftwareForDeclaredInterlace is hard-gated to H.264 only (VideoRoutingPolicy.swift:58).
  • Deinterlace filter engaging on progressive content. DeinterlaceFilter only activates on the decoded frame's actual AV_FRAME_FLAG_INTERLACED flag, checked per-frame (SoftwareVideoDecoder.swift:235-269). Confirmed via direct ffprobe -show_frames against the reproducing file: repeat_pict=0 on every frame in a 37.5s sample — no pulldown/interlace signaling present in the bitstream at all.
  • Historical PTS-doubling bug (eedaa5b5). Fixed since 3.11.2, and in any case only fires when DeinterlaceFilter is active — moot here since the deinterlacer never engages for progressive content.
  • Transport/bitrate. Identical judder over both a 1 Gbps Jellyfin stream and a 10 Gbps local SMB mount rules out network throughput and IO buffering as a cause.
  • Container/demuxer-level timestamp irregularity. ffprobe -show_entries frame=pts_time,best_effort_timestamp_time,pkt_dts_time,pict_type,repeat_pict against the actual reproducing file shows best_effort_timestamp_time populated on 100% of frames, evenly spaced at the expected ~41.7/41.6/41.7ms cadence for 23.976fps, with no gaps or discontinuities across the full sample. The source data and FFmpeg's own reconstruction of it are both clean.

The finding

In the same ffprobe output, raw pts_time (i.e., AVFrame.pts as the VC-1 decoder set it, before any FFmpeg-side reconstruction) is N/A on every I-frame and every P-frame in the sample — only B-frames carry a valid raw PTS. This split is total and consistent across the entire 37.5s capture (GOP pattern I B P B P; 3 of every 5 frames have no raw PTS). best_effort_timestamp, which FFmpeg computes specifically to paper over exactly this kind of gap using DTS/reorder-depth reconstruction, is complete and correct for the same frames.

This means: whatever timestamp AetherEngine's software decoder hands to the renderer for 60% of frames in this file depends entirely on which field it reads. If it reads raw pts (matching SoftwareVideoDecoder.swift's apparent per-frame handling in drainDecodedFrames, SoftwareVideoDecoder.swift:235-269) rather than best_effort_timestamp, most frames arrive with no usable timestamp and whatever fallback is used — extrapolation, last-known-PTS reuse, arrival-time estimation — is a strong candidate for the judder's actual source.

This is also why the symptom is AetherEngine-specific despite shared FFmpeg internals: mpv and other FFmpeg-based players never consume raw frame->pts for exactly this reason, always using best_effort_timestamp instead. And it's why the software-decode presentation path is unusually sensitive to any timestamp error in the first place — AVSampleBufferDisplayLayer presents each sample strictly at the CMTime it's given, with no vsync-aware cadence model or interpolation to absorb an inaccurate value the way mpv's own pacing loop would.

(Why VC-1's decoder produces this split — B-frames tagged, I/P frames losing their association during internal reference-picture buffering — is a plausible mechanism based on the observed pattern, not something confirmable without libavcodec source access. It doesn't change the recommended fix.)

Recommended fix

  1. In SoftwareVideoDecoder.swift's frame emission path, switch from reading the decoded frame's raw pts to its best_effort_timestamp (bridged via av_frame_get_best_effort_timestamp() if not already exposed as a Swift-accessible property). This is confirmed complete and correctly-paced for the reproducing file and should need no additional reconstruction logic.
  2. Audit whatever fallback currently runs when pts == AV_NOPTS_VALUE — that code path is exercised on the majority of frames in files like this one and is the immediate suspect for the visible judder.
  3. Once switched, set an explicit duration on each CMSampleTimingInfo (not just presentationTimeStamp), so CMSampleBufferMake/AVSampleBufferDisplayLayer has the intended per-frame duration rather than inferring it from enqueue timing.

Supporting data

  • ffprobe -show_frames output against the reproducing file (pts_time, pkt_dts_time, best_effort_timestamp_time, pict_type, repeat_pict), ~900-frame / 37.5s sample — available on request, confirms the 100%-consistent I/P pts_time=N/A pattern and fully clean best_effort_timestamp_time sequence.
  • Stream metadata: vc1 (Advanced) (WVC1 / 0x31435657), yuv420p(progressive), 1920x1080, 23.98 fps, 23.98 tbr, 1k tbn, Matroska container, TimecodeScale default (ms granularity).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions