Description
On iOS, useVideo renders the first frame and then freezes permanently: after the initial frame, neither playback (paused → false) nor seek ever produces another frame. The decoder looks alive (shaders over it keep animating) but the picture is a still image.
We instrumented the returned currentTime/frames from JS while issuing seeks: currentTime stays frozen while seeks keep being issued — i.e. even seek() (which calls expectFrame() natively) stops producing frames after the first one.
Root cause
RNSkAppleVideo pulls frames from an AVPlayerItemVideoOutput in onDisplayLink() via hasNewPixelBufferForItemTime: / copyPixelBufferForItemTime:.
Apple documents that AVPlayerItemVideoOutput suspends itself when the client stops pulling buffers for a while; once suspended, hasNewPixelBufferForItemTime: returns NO forever. The supported recovery is the pull-delegate dance (setDelegate: + requestNotificationOfMediaDataChangeWithAdvanceInterval: → outputMediaDataWillChange:), which RNSkAppleVideo doesn't implement. The display link is paused whenever the video is paused and not awaiting a frame — exactly the "client stopped pulling" condition — so the output suspends and the video is dead from then on: play() unpauses the display link but every hasNewPixelBufferForItemTime: check returns NO, and seek()'s expectFrame() can never deliver.
There is a second, independent hazard in the same file: the AVPlayer is constructed on the worklet runtime thread (Skia.Video() is invoked from video-metadata-runtime in useVideoLoading) and play()/pause() are then called from Reanimated's UI thread. AVPlayer is documented for main-thread use, and cross-thread play is the classic "silently does nothing" call.
Reproduction
@shopify/react-native-skia 2.6.2, RN 0.86, Reanimated 4.5, new architecture, Expo SDK 57, iOS device build.
useVideo(url, { paused, seek, volume }) with paused/seek shared values (the documented pattern).
- Mount with
paused: true (a seek fires once and one frame renders), then set paused.value = false.
- Result: frame never changes; subsequent writes to
seek.value do nothing. currentTime never advances.
Proposed fix (patch we're shipping via patch-package)
Short of implementing the full pull-delegate protocol, re-attaching a fresh AVPlayerItemVideoOutput recovers reliably — a newly attached output reports the current frame as new. We detect starvation in onDisplayLink() (~20 consecutive display-link ticks with no new buffer while _isPlaying || _waitingForFrame) and swap the output; we also dispatch play/pause to the main queue.
void RNSkAppleVideo::refreshVideoOutput() {
if (!_playerItem) return;
if (_videoOutput) [_playerItem removeOutput:_videoOutput];
_videoOutput = [[AVPlayerItemVideoOutput alloc]
initWithOutputSettings:getOutputSettings()];
[_playerItem addOutput:_videoOutput];
}
void RNSkAppleVideo::onDisplayLink() {
CMTime outputItemTime =
[_videoOutput itemTimeForHostTime:CACurrentMediaTime()];
if (![_videoOutput hasNewPixelBufferForItemTime:outputItemTime] &&
(_isPlaying || _waitingForFrame)) {
if (++_noFrameCount > 20) { // ~1/3s starved while we SHOULD have frames
refreshVideoOutput();
_noFrameCount = 0;
}
return;
}
// ... existing copyPixelBufferForItemTime path, with _noFrameCount = 0 on success
}
void RNSkAppleVideo::play() {
if (_player) {
AVPlayer *player = _player;
dispatch_async(dispatch_get_main_queue(), ^{ [player play]; });
_isPlaying = true;
_noFrameCount = 0;
_displayLink.paused = NO;
}
}
The complete diff is small (one new ivar + method in RNSkAppleVideo.h, the changes above in RNSkAppleVideo.mm). Happy to open a PR if this direction is acceptable — or the fuller fix is adopting the AVPlayerItemOutputPullDelegate protocol so the output never silently dies in the first place.
Caveat for transparency: the diagnosis is from observed device behaviour plus reading the implementation; we're verifying the patch on-device in our next build and will report back.
Description
On iOS,
useVideorenders the first frame and then freezes permanently: after the initial frame, neither playback (paused→ false) norseekever produces another frame. The decoder looks alive (shaders over it keep animating) but the picture is a still image.We instrumented the returned
currentTime/frames from JS while issuing seeks:currentTimestays frozen while seeks keep being issued — i.e. evenseek()(which callsexpectFrame()natively) stops producing frames after the first one.Root cause
RNSkAppleVideopulls frames from anAVPlayerItemVideoOutputinonDisplayLink()viahasNewPixelBufferForItemTime:/copyPixelBufferForItemTime:.Apple documents that
AVPlayerItemVideoOutputsuspends itself when the client stops pulling buffers for a while; once suspended,hasNewPixelBufferForItemTime:returnsNOforever. The supported recovery is the pull-delegate dance (setDelegate:+requestNotificationOfMediaDataChangeWithAdvanceInterval:→outputMediaDataWillChange:), whichRNSkAppleVideodoesn't implement. The display link is paused whenever the video is paused and not awaiting a frame — exactly the "client stopped pulling" condition — so the output suspends and the video is dead from then on:play()unpauses the display link but everyhasNewPixelBufferForItemTime:check returnsNO, andseek()'sexpectFrame()can never deliver.There is a second, independent hazard in the same file: the
AVPlayeris constructed on the worklet runtime thread (Skia.Video()is invoked fromvideo-metadata-runtimeinuseVideoLoading) andplay()/pause()are then called from Reanimated's UI thread.AVPlayeris documented for main-thread use, and cross-threadplayis the classic "silently does nothing" call.Reproduction
@shopify/react-native-skia2.6.2, RN 0.86, Reanimated 4.5, new architecture, Expo SDK 57, iOS device build.useVideo(url, { paused, seek, volume })withpaused/seekshared values (the documented pattern).paused: true(a seek fires once and one frame renders), then setpaused.value = false.seek.valuedo nothing.currentTimenever advances.Proposed fix (patch we're shipping via patch-package)
Short of implementing the full pull-delegate protocol, re-attaching a fresh
AVPlayerItemVideoOutputrecovers reliably — a newly attached output reports the current frame as new. We detect starvation inonDisplayLink()(~20 consecutive display-link ticks with no new buffer while_isPlaying || _waitingForFrame) and swap the output; we also dispatchplay/pauseto the main queue.The complete diff is small (one new ivar + method in
RNSkAppleVideo.h, the changes above inRNSkAppleVideo.mm). Happy to open a PR if this direction is acceptable — or the fuller fix is adopting theAVPlayerItemOutputPullDelegateprotocol so the output never silently dies in the first place.Caveat for transparency: the diagnosis is from observed device behaviour plus reading the implementation; we're verifying the patch on-device in our next build and will report back.