Duck
Duck

Reputation: 35953

How to keep low latency during the preview of video coming from AVFoundation?

Apple has a sample code called Rosy Writer that shows how to capture video and apply effects to it.

During this section of the code, on the outputPreviewPixelBuffer part, Apple supposedly shows how they keep preview latency low by dropping stale frames.

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
{
    CMFormatDescriptionRef formatDescription = CMSampleBufferGetFormatDescription( sampleBuffer );

    if ( connection == _videoConnection )
    {
        if ( self.outputVideoFormatDescription == NULL ) {
            // Don't render the first sample buffer.
            // This gives us one frame interval (33ms at 30fps) for setupVideoPipelineWithInputFormatDescription: to complete.
            // Ideally this would be done asynchronously to ensure frames don't back up on slower devices.
            [self setupVideoPipelineWithInputFormatDescription:formatDescription];
        }
        else {
            [self renderVideoSampleBuffer:sampleBuffer];
        }
    }
    else if ( connection == _audioConnection )
    {
        self.outputAudioFormatDescription = formatDescription;

        @synchronized( self ) {
            if ( _recordingStatus == RosyWriterRecordingStatusRecording ) {
                [_recorder appendAudioSampleBuffer:sampleBuffer];
            }
        }
    }
}

- (void)renderVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer
{
    CVPixelBufferRef renderedPixelBuffer = NULL;
    CMTime timestamp = CMSampleBufferGetPresentationTimeStamp( sampleBuffer );

    [self calculateFramerateAtTimestamp:timestamp];

    // We must not use the GPU while running in the background.
    // setRenderingEnabled: takes the same lock so the caller can guarantee no GPU usage once the setter returns.
    @synchronized( _renderer )
    {
        if ( _renderingEnabled ) {
            CVPixelBufferRef sourcePixelBuffer = CMSampleBufferGetImageBuffer( sampleBuffer );
            renderedPixelBuffer = [_renderer copyRenderedPixelBuffer:sourcePixelBuffer];
        }
        else {
            return;
        }
    }

    if ( renderedPixelBuffer )
    {
        @synchronized( self )
        {
            [self outputPreviewPixelBuffer:renderedPixelBuffer];

            if ( _recordingStatus == RosyWriterRecordingStatusRecording ) {
                [_recorder appendVideoPixelBuffer:renderedPixelBuffer withPresentationTime:timestamp];
            }
        }

        CFRelease( renderedPixelBuffer );
    }
    else
    {
        [self videoPipelineDidRunOutOfBuffers];
    }
}

// call under @synchronized( self )
- (void)outputPreviewPixelBuffer:(CVPixelBufferRef)previewPixelBuffer
{
    // Keep preview latency low by dropping stale frames that have not been picked up by the delegate yet
    // Note that access to currentPreviewPixelBuffer is protected by the @synchronized lock
    self.currentPreviewPixelBuffer = previewPixelBuffer; // A

    [self invokeDelegateCallbackAsync:^{  // B

        CVPixelBufferRef currentPreviewPixelBuffer = NULL; // C
        @synchronized( self ) //D
        {
            currentPreviewPixelBuffer = self.currentPreviewPixelBuffer; // E
            if ( currentPreviewPixelBuffer ) { // F
                CFRetain( currentPreviewPixelBuffer ); // G
                self.currentPreviewPixelBuffer = NULL;  // H
            }
        }

        if ( currentPreviewPixelBuffer ) { // I
            [_delegate capturePipeline:self previewPixelBufferReadyForDisplay:currentPreviewPixelBuffer];  // J
            CFRelease( currentPreviewPixelBuffer );  /K
        }
    }];
}

- (void)invokeDelegateCallbackAsync:(dispatch_block_t)callbackBlock
{
    dispatch_async( _delegateCallbackQueue, ^{
        @autoreleasepool {
            callbackBlock();
        }
    } );
}

After hours of trying to understand this code, my brain is smoking and I cannot see how this is done.

Can someone explain like I am 5 years old, OK, make it 3 years old, how is this code doing that?

thanks.

EDIT: I have labeled the lines of outputPreviewPixelBuffer with letters to make it easy to understand the order the code is being executed.

So, the method starts and A runs and the buffer is stored into the property self.currentPreviewPixelBuffer. B runs and the local variable currentPreviewPixelBuffer is assigned with NULL. D runs and locks self. Then E runs and changes the local variable currentPreviewPixelBuffer from NULL to the value of self.currentPreviewPixelBuffer.

This is the first thing that does not makes sense. Why would I create a variable currentPreviewPixelBuffer assign it to NULL and on the next line assign it to self.currentPreviewPixelBuffer?

The following line is even more insane. Why I am asking if currentPreviewPixelBuffer is not NULL If I just assigned it to a non NULL value on E? Then H is executed and nulls self.currentPreviewPixelBuffer?

One thing I don't get is this: invokeDelegateCallbackAsync: is asynchronous, right? if it is asynchronous then every time outputPreviewPixelBuffer method runs is to set self.currentPreviewPixelBuffer = previewPixelBuffer and dispatch a block for execution, being free to run again.

If outputPreviewPixelBuffer is fired faster, we will have a bunch of blocks piled for execution.

Due to the explanations of Kamil Kocemba, I undestand that these asynchronous blocks are testing somehow if the previous one finished executing and dropping the frames if not.

Also, what exactly is @syncronized(self) locking? Is it preventing self.currentPreviewPixelBuffer from being written or read? or is it locking the local variable currentPreviewPixelBuffer? If the block under @syncronized(self) is synchronous with relation to the scope the line at I will never be NULL because it is being set on E.

Upvotes: 2

Views: 857

Answers (2)

Itai Ferber
Itai Ferber

Reputation: 29834

Thank you for highlighting the lines -- this will hopefully make the answer a little bit easier to follow.

Let's go through step by step:

  1. -outputPreviewPixelBuffer: is called. self.currentPreviewPixelBuffer is overwritten not in an @synchronized block: this means that it is forcibly overwritten, effectively for all threads (I'm glossing over the fact that currentPreviewPixelBuffer is nonatomic; this is actually unsafe and there is a race here -- you really need it to be strong, atomic for this to be really true). If there was a buffer in there, it's now gone the next time a thread is going to go looking for it. This is what the documentation implies -- if there was a value in self.currentPreviewPixelBuffer and the delegate has not yet gotten to process the previous value, too bad! It's gone now.

  2. The block is sent to the delegate to process asynchronously. In effect, this will likely happen sometime in the future, with some indeterminate delay. This means that between when -outputPreviewPixelBuffer: is called and when the block is processed, -outputPreviewPixelBuffer: can get called again many, many times! This is how the stale frames are dropped -- if it's taking a long time for the delegate to get to processing the block, the latest self.currentPreviewPixelBuffer will get overwritten with the latest value again and again, effectively dropping the previous frame.

  3. Lines C through H take ownership of self.currentPreviewPixelBuffer. You indeed have a local pixel buffer, initially set to NULL. The @synchronized block around self says, implicitly: "I am going to moderate access to self, to make sure no one edits self while I'm looking at it, and also I will ensure that I grab the most up-to-date value of self's instance variables, even across threads". This is how the delegate ensures that it has the latest self.currentPreviewPixelBuffer; if it was not @synchronized, you could get a stale copy.

    Also in the @synchronized block is the overwrite of self.currentPreviewPixelBuffer, after retaining it. This code implicitly says: "hey, if self.currentPreviewPixelBuffer is not NULL, then there must be a pixel buffer to process; if there is (line F), then I'll hold on to it (line E, G), and reset it on self (line H)". In effect, this takes ownership of self's currentPreviewPixelBuffer so that no one else will process it. This is an implicit check for all delegate callback blocks operating on self: the first block to fire that looks at self.currentPreviewPixelBuffer gets to keep it, sets it to NULL for all other blocks looking at self, and does work with it. The others, having read NULL on line F, do nothing.

  4. Lines I and J actually use the pixel buffer, and line K disposes of it properly.

It's true, this code could use some commenting -- it's really lines E through G that do a lot of the implicit work here, taking ownership of self's preview buffer to keep others from processing the block as well. What the comment above line A does not say is, "Note that access to currentPreviewPixelBuffer is protected by @synchronized..., in contrast to here where it's not; because it's not protected by that here, we can overwrite self.currentPreviewPixelBuffer as many times as we want before someone processes it, dropping the intermediate values"

Hope that helps.

Upvotes: 3

kkodev
kkodev

Reputation: 2607

OK, that's the interesting part:

// call under @synchronized( self )
- (void)outputPreviewPixelBuffer:(CVPixelBufferRef)previewPixelBuffer
{
    // Keep preview latency low by dropping stale frames that have not been picked up by the delegate yet
    // Note that access to currentPreviewPixelBuffer is protected by the @synchronized lock
    self.currentPreviewPixelBuffer = previewPixelBuffer;

    [self invokeDelegateCallbackAsync:^{

        CVPixelBufferRef currentPreviewPixelBuffer = NULL;
        @synchronized( self )
        {
            currentPreviewPixelBuffer = self.currentPreviewPixelBuffer;
            if ( currentPreviewPixelBuffer ) {
                CFRetain( currentPreviewPixelBuffer );
                self.currentPreviewPixelBuffer = NULL;
            }
        }

        if ( currentPreviewPixelBuffer ) {
            [_delegate capturePipeline:self previewPixelBufferReadyForDisplay:currentPreviewPixelBuffer];
            CFRelease( currentPreviewPixelBuffer );
        }
    }];
}

Basically what they do is use currentPreviewPixelBuffer property to track if the frame is stale.

If a frame is being processed for display (invokeDelegateCallbackAsync:) that property is set to NULL effectively discarding any enqueued frame (that would be waiting there for processing).

Note that this callback is being invoked asynchronously. Each captured frame calls outputPreviewPixelBuffer: and each displayed frame needs a call to _delegate capturePipeline:previewPixelBufferReadyForDisplay:.

Stale frames mean that outputPreviewPixelBuffer is called more often ('faster') that delegate can process them. In such case however the property (which 'enqueues' next frame) will be set to NULL and callback will return immediately, leaving room for only the most recent frame.

Does it make sense to you?

EDIT:

Imagine following sequence of calls (very simplified):

TX = task X, FX = frame X

T1. output preview (F1)
T2. delegate callback start (F1)
T3. output preview (F2)
T4. output preview (F3)
T5. output preview (F4)
T6. output preview (F5)
T7. delegate callback stop (F1)

Callbacks for T3, T4, T5 and T6 wait on @synchronized(self) lock.

When T7 finishes what's the value of self.currentPreviewPixelBuffer?

It's F5.

We then run delegate callback for T3.

Do self.currentPreviewPixelBuffer = NULL

Delegate callback finishes.

We then run delegate callback for T4.

What's the value of self.currentPreviewPixelBuffer?

It's NULL.

So it's no-op.

Same for callbacks for T5 and T6.

Processed frames: F1 and F5. Dropped frames: F2, F3, F4.

Hope this helps

Upvotes: 2

Related Questions