1

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.

Duck
  • 34,902
  • 47
  • 248
  • 470
  • can you share the link to the source code ? Im also interested in learning how to have low latency when editing the sampleBuffers coming from the camera – omarojo Mar 23 '17 at 04:43
  • 2
    the link is on the first paragraph. – Duck Mar 23 '17 at 10:24

2 Answers2

2

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

kkodev
  • 2,557
  • 23
  • 23
  • sorry, I am not seeing it. Lets consider the first line of the code `self.currentPreviewPixelBuffer = previewPixelBuffer;` at this line the property is set with `previewPixelBuffer` value and it is theoretically not nil. So, we have a valid buffer there. Then an asynchronous block runs and I don't see how this block detects stale frames. I see a @synchronized instruction there. I know what this instruction does in theoretical terms but I am also failing to understand in terms of the video frames coming, what this is exactly doing. Also, the first line of the code has also a similar comment – Duck Nov 23 '16 at 16:23
  • remember... explain it like I am 3 years old... – Duck Nov 23 '16 at 16:26
  • this code makes no sense: `CVPixelBufferRef currentPreviewPixelBuffer = NULL;` is executed making it null. Then a @syncronize runs making the variable not null at the end an `if` asks if it is null. A completely insane code. – Duck Nov 23 '16 at 16:50
  • There are 2 references: `self.currentPreviewPixelBuffer` and `previewPixelBuffer`. You need 2 because the first one, property, is used to store incoming frames while the latter is used to pass a frame to delegate for display. Please note that these may reference different frames. Setting `previewPixelBuffer` to `NULL` basically means: 'I'm ready to start processing next frame`. – kkodev Nov 23 '16 at 17:14
  • I agree with you, Apple often creates samples showing great features but the code is mind-numbingly complicated. – kkodev Nov 23 '16 at 17:17
  • Grab a pen and paper and imagine `outputPreviewPixelBuffer:` takes 1 second to return and delegate callback takes 5 seconds to return. Run a small simulation on paper. Observe how these 2 references behave. You will notice that you are dropping frames. That's because you don't queue them. – kkodev Nov 23 '16 at 17:23
  • Damn, I meant `currentPreviewPixelBuffer` not `previewPixelBuffer` in the first comment – kkodev Nov 23 '16 at 17:24
  • Thanks for the effort but sorry, some stuff is not clear yet. I have added more info to the end of my question. Sorry bothering you but this theme appears to be interesting and I am not willing to give up easy! please read the new info I have added. – Duck Nov 23 '16 at 20:22
2

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.

Community
  • 1
  • 1
Itai Ferber
  • 28,308
  • 5
  • 77
  • 83
  • 1
    I think I am starting to grasp it now. Your explanation is amazing. I have to make a remark here to the way Apple write their documentations. I think 99.99% of everything Apple writes as documentation is pure garbage. Their sample code are too complex to explain basic things and poorly recommended. Their reference guides show how they hate developers and to write documentation. I am from the old school where documentations were master pieces of didactics and explanation. I am a book writer myself and I see how bad Apple docs are. If was not for SO we would be all doomed. Long live SO! – Duck Nov 24 '16 at 10:49
  • @SpaceDog Glad to have helped. FWIW, I don't think it's fair to say that Apple hates developers or documentation -- if that were the case, there wouldn't be any to speak of. We care very much about making our public-facing documentation as accessible as possible (and whether you think I'm biased or not, I do think we have some excellent documentation for our frameworks). I agree that some of the code examples can be difficult to get through, but keep in mind that it's code, not prose. – Itai Ferber Nov 24 '16 at 18:19
  • @SpaceDog It's impossible to put together a code sample without making decisions about minutae; in prose you can gloss over some details, but in code, it's not possible. This means that you either have to leave some decisions opaque (e.g. how does this drop frames?), or you have to leave tons of comments _everywhere_ to try to reach people of all skill levels. This can leave the code inundated with unrelated explanation and hard to read; it's difficult to strike an optimal balance. That's just the experience of delving into someone else's codebase; it's never easy, but we can fill in the gaps. – Itai Ferber Nov 24 '16 at 18:19
  • @SpaceDog In any case, if you think any code sample is unclear, file a bug about it. Feel free to link this SO question (or any others you have) in your report, and explain what you feel is missing -- it will get reviewed and fixed. – Itai Ferber Nov 24 '16 at 18:20
  • thanks but I will pass on that. I have filled almost 150 bug/feature/documentation reports in the past and no corrections were made. What I think is a piece of crap they may think is state-of-the-art and the thing remains as it is. Unfortunately filling anything is a waste of time in my opinion. They just fix what gives or may give them bad publicity, 99.99% of the time. – Duck Nov 24 '16 at 18:46