Scrubbing a live session on the software path shows no preview frame. The bar moves, the wall clock under the knob moves, the card above it stays empty, on every channel that path carries. Reported against Sodalite as part of superuser404notfound/Sodalite#104 and deliberately kept out of that round so it would not ride along with a correctness fix.
Why it is empty
liveScrubThumbnail(atSessionSeconds:) opens with
guard isLive, let session = nativeVideoSession else { return nil }
and nativeVideoSession is assigned only in loadNative. A software session has none, so it has no SegmentCache, and the still is nil by construction rather than by a miss. supportsCacheBackedStills says the same thing one layer up, and its own comment already names this case.
There is no second source to fall back on either. A host can pair the VOD arm with makeFrameExtractor(), whose second demuxer seeks a file happily, which is why software VOD (AV1 without hardware decode, VP9) still shows stills. A live source is forward-only, so that fallback does not exist on live, and nothing else does.
That is every live channel whose video the box cannot decode in hardware. On an ATSC tuner it is all of them: MPEG-2 has no hardware decoder on Apple TV, so an HDHomeRun channel lands on SoftwarePlaybackHost every time. The route is not the cause, the codec is.
What it should read instead
The software path already holds what a still needs. PacketRingBuffer spools the whole timeshift window to disk, one file per demuxed packet, each entry carrying its pts and an isKeyframe flag, and seq(forKeyframeAtOrBefore:) already answers the exact question a still has to ask, because seekLiveDVR asks it for every DVR rewind. If the knob can land somewhere, the packets for that moment are resident.
So: find the keyframe at or before the target, walk video packets forward into a decoder until the target pts, convert the last frame. No network, no second demuxer, no segment cache, and the same buffer the scrubber is already seeking within, so a still cannot disagree with where the commit lands.
Shape
- The still decodes on its own queue. The ring is internally locked and documented as safe to read from outside the feeder, but the extractor must not run on
demuxQueue or feedQueue or playback pays for the picture.
- One still decoder is held across requests and flushed on a backward jump, rather than opened per request. A host holding a seek scrubs at roughly one request per 80 ms.
- The forward walk from the keyframe is bounded in packets and in seconds. A pathological GOP returns nil instead of decoding for minutes.
- Not wrapped in a container first.
MP4SegmentMuxer exists, but it is tuned for what AVPlayer accepts (codec tag overrides, Dolby Vision configuration), and this picture never goes near AVPlayer. Writing elementary streams into a container so a demuxer can take them out again buys nothing and would put MPEG-2 and VC-1 through a muxer that has never seen them.
Out of scope
- Live on the
nativeRemoteHLS bypass (IPTV). AVPlayer holds those bytes, there is nothing engine-side to decode, and a still there is a different mechanism.
- A VOD source forced to software because it is forward-only. The extractor cannot seek it either, and there is no backward buffer to read: the software VOD spool is a forward FIFO.
- Software VOD in general. It has stills today through the extractor.
Host side
None. Sodalite already calls liveScrubThumbnail on every live scrub tick and takes nil for an answer; the card fills as soon as the second arm answers.
Scrubbing a live session on the software path shows no preview frame. The bar moves, the wall clock under the knob moves, the card above it stays empty, on every channel that path carries. Reported against Sodalite as part of superuser404notfound/Sodalite#104 and deliberately kept out of that round so it would not ride along with a correctness fix.
Why it is empty
liveScrubThumbnail(atSessionSeconds:)opens withand
nativeVideoSessionis assigned only inloadNative. A software session has none, so it has noSegmentCache, and the still is nil by construction rather than by a miss.supportsCacheBackedStillssays the same thing one layer up, and its own comment already names this case.There is no second source to fall back on either. A host can pair the VOD arm with
makeFrameExtractor(), whose second demuxer seeks a file happily, which is why software VOD (AV1 without hardware decode, VP9) still shows stills. A live source is forward-only, so that fallback does not exist on live, and nothing else does.That is every live channel whose video the box cannot decode in hardware. On an ATSC tuner it is all of them: MPEG-2 has no hardware decoder on Apple TV, so an HDHomeRun channel lands on
SoftwarePlaybackHostevery time. The route is not the cause, the codec is.What it should read instead
The software path already holds what a still needs.
PacketRingBufferspools the whole timeshift window to disk, one file per demuxed packet, each entry carrying itsptsand anisKeyframeflag, andseq(forKeyframeAtOrBefore:)already answers the exact question a still has to ask, becauseseekLiveDVRasks it for every DVR rewind. If the knob can land somewhere, the packets for that moment are resident.So: find the keyframe at or before the target, walk video packets forward into a decoder until the target pts, convert the last frame. No network, no second demuxer, no segment cache, and the same buffer the scrubber is already seeking within, so a still cannot disagree with where the commit lands.
Shape
demuxQueueorfeedQueueor playback pays for the picture.MP4SegmentMuxerexists, but it is tuned for what AVPlayer accepts (codec tag overrides, Dolby Vision configuration), and this picture never goes near AVPlayer. Writing elementary streams into a container so a demuxer can take them out again buys nothing and would put MPEG-2 and VC-1 through a muxer that has never seen them.Out of scope
nativeRemoteHLSbypass (IPTV). AVPlayer holds those bytes, there is nothing engine-side to decode, and a still there is a different mechanism.Host side
None. Sodalite already calls
liveScrubThumbnailon every live scrub tick and takes nil for an answer; the card fills as soon as the second arm answers.