8

I'm writing an Android application, and in it, I have a VirtualDisplay to mirror what is on the screen and I then send the frames from the screen to an instance of a MediaCodec. It works, but, I want to add a way of specifying the FPS of the encoded video, but I'm unsure how to do so.

From what I've read and experimented with, dropping encoded frames (based on the presentation times) doesn't work well as it ends up with blocky/artifact ridden video as opposed to a smooth video at a lower framerate. Other reading suggests that the only way to do what I want (limit the FPS) would be to limit the incoming FPS to the MediaCodec, but the VirtualDisplay just receives a Surface which is constructed from the MediaCodec as below

mSurface = <instance of MediaCodec>.createInputSurface();
mVirtualDisplay = mMediaProjection.createVirtualDisplay(
    "MyDisplay",
    screenWidth,
    screenHeight,
    screenDensity,
    DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
    mSurface,
    null,
    null);

I've also tried subclassing Surface and limit the frames that are fed to the MediaCodec via the unlockCanvasAndPost(Canvas canvas) but the function never seems to be called on my instance, so, there may be some weirdness in how I extended Surface and the interaction with the Parcel as writeToParcel function is called on my instance, but that is the only function that is called in my instance (that I can tell).

Other reading suggests that I can go from encoder -> decoder -> encoder and limit the rate in which the second encoder is fed frames, but that's a lot of extra computation that I'd rather not do if I can avoid it.

Has anyone successfully limited the rate at which a VirtualDisplay feeds its Surface? Any help would be greatly appreciated!

Nikola Despotoski
  • 49,966
  • 15
  • 119
  • 148
EncodedNybble
  • 265
  • 2
  • 11

3 Answers3

11

Starting off with what you can't do...

You can't drop content from the encoded stream. Most of the frames in the encoded stream are essentially "diffs" from other frames. Without knowing how the frames interact, you can't safely drop content, and will end up with that corrupted macroblock look.

You can't specify the frame rate to the MediaCodec encoder. It might stuff that into metadata somewhere, but the only thing that really matters to the codec is the frames you're feeding into it, and the presentation time stamps associated with each frame. The encoder will not drop frames.

You can't do anything useful by subclassing Surface. The Canvas operations are only used for software rendering, which is unrelated to feeding in frames from a camera or virtual display.

What you can do is send the frames to an intermediate Surface, and then choose whether or not to forward them to the MediaCodec's input Surface. One approach would be to create a SurfaceTexture, construct a Surface from it, and pass that to the virtual display. When the SurfaceTexture's frame-available callback fires, you either ignore it, or render the texture onto the MediaCodec input Surface with GLES.

Various examples can be found in Grafika and on bigflake, none of which are an exact fit, but all of the necessary EGL and GLES classes are there.

fadden
  • 51,356
  • 5
  • 116
  • 166
  • Thank you for your input fadden! Sounds pretty good (why didn't I think of that?). I'm a graphics engineer, so I should be pretty set with the OpenGL calls for the construction of the texture, etc. etc. I'll give this a shot and report back. – EncodedNybble Jul 22 '15 at 22:56
  • So, I've noticed that SurfaceTexture handles its on frame available callbacks by receiving a message on either the current looper, or the UI thread looper, or none. Which is...interesting. Wish there was a way to specify the handler or looper. Since I am doing all of this work in a HandlerThread, this will take some working around. – EncodedNybble Jul 24 '15 at 16:12
  • Just send a message. A typical `onFrameAvailable()` implementation looks like this: https://github.com/google/grafika/blob/master/src/com/android/grafika/TextureFromCameraActivity.java#L668 – fadden Jul 24 '15 at 17:42
  • I should also mention that my app is in the background (I'm experimenting). – EncodedNybble Jul 24 '15 at 20:51
  • I never get a onFrameAvailable callback if my application is in the background. I'll keep poking around to see what I can figure out. – EncodedNybble Jul 25 '15 at 01:06
  • The CTS tests are more or less "in the background". The most common problem is waiting for an event on the thread that is also supposed to deliver the event, but it sounds like you're already aware of that. – fadden Jul 25 '15 at 01:35
  • Ok, thanks for the feedback. I mean that the app is literally backgrounded, not just the work being done on a background thread. I have a separate thread (with own Looper) that the SurfaceTexture/Surface are created on. The thread isn't blocking (the Looper begins via Looper.loop() fine. So I don't think waiting/blocking on the same thread is the problem. Does egl WindowSurface vs eglPbufferSurface make a difference? I use eglCreateWindowSurface because I use a Surface (from `MediaCodec.createInputSurface`) as a parameter. Still not sure if I set everything up correctly. – EncodedNybble Jul 25 '15 at 02:06
  • I'm having a little trouble picturing what you're doing. It should go VirtualDisplay -> Surface(SurfaceTexture), with onFrameAvailable callbacks there. Nothing relating to MediaCodec or window surfaces matters if you're not getting any frames from the input side, so you could just disable all that to simplify matters. FWIW, the key Looper-related code is here: https://android.googlesource.com/platform/frameworks/base/+/lollipop-release/graphics/java/android/graphics/SurfaceTexture.java#192 . The event burbles its way up around line 363. – fadden Jul 25 '15 at 04:23
  • You are correct. It does go Virtual Display -> Surface(SurfaceTexture). I was getting ahead of myself, sorry. Since the Surface(SurfaceTexture) is created in another thread (so that the callback won't be blocked), I have to create a new EGL context + display and make the context current or else the GL texture creation always failed (well, returned 0). Yeah, I had checked out that source before. I'll try disabling everything that's not necessary to see if I can narrow down the problem. Thanks again for your help. – EncodedNybble Jul 25 '15 at 05:22
  • Sorry, I would move this discussion to chat, but I don't have the reputation points for that. I've removed cruft and want to get a *single* frame available callback from a Surface I create. Another application is running, my app is in the background. No frame callbacks are fired. If the other app leaves immersive mode (I swipe up from the bottom, exposing the 3 software buttons), *I do* get a frame callback. Would immersive mode affect the callback at all? – EncodedNybble Jul 25 '15 at 16:15
  • It wouldn't affect the SurfaceTexture. I'm wondering if something you're doing is affecting the VirtualDisplay, causing it to generate no output. I don't know enough about the implementation of that to be sure. Of course, that would cause problems when you were feeding it directly into MediaCodec, so if *that* was working then this should work as well. – fadden Jul 26 '15 at 00:29
  • Well, it definitely works just using the surface from `MediaCodec`, so, I guess it is something I'm doing wrong. Thanks for the feedback. Never thought making a intermediary Surface would be so difficult. – EncodedNybble Jul 26 '15 at 00:56
  • While I have yet to get something actually working, I will accept the answer from @fadden as I believe it should conceptually work. My implementation is just lacking. – EncodedNybble Jul 27 '15 at 21:02
  • you guys are talking about lowering the fps coming from virtual display, what about the other way around? I see that frames from MediaProjection are only coming when there is action on a screen, this results in a video with inconsistent gaps in PTSs. I see players (Chrome) are struggling playing back these kinds of videos. Is not up to standard? Is there a way to enforce codec to generate skip frames somehow at a constant rate? Thanks – Dmitry Fink Jan 29 '16 at 18:25
  • @DmitryFink: that's easy. Suppose you want frames at times 1,2,3,4, but you only get frames at times 1 and 4. When frame 4 arrives, just re-send frame 1 to the encoder twice, "forging" timestamps 2 and 3. Use an intermediate SurfaceTexture and hold off calling `updateTexImage()` until you're sure the previous frame is no longer needed. (If you need more details, post your question as a question.) – fadden Jan 29 '16 at 21:49
  • @DmitryFink: what I wrote in the previous comment probably won't work if the gaps are too large, because you're recording in real time -- filling in a twenty-second pause at 30fps could take a while, and you could drop frames. Rather than waiting for the next real frame, you'd want a timeout that says "frame N should have arrived by now", at which point you feed the previously-seen frame into the encoder. The trick is leave a little room for lag in the design, so you don't start sending duplicate frames if the system is a bit slow to run the frame-available callback. – fadden Jan 31 '16 at 17:15
  • I actually got an intermediary `Surface` up and running a while ago and it works pretty well. The only thing I'm struggling with now is seeing if there is a way to resize a `Surface` and `SurfaceTexture` without needing to create new ones. Seems like it should be doable, but I'm having some issues with abandoned buffer queues when attempting to go from `VirtualDisplay` -> my intermediary `Surface` and resizing the intermediary `Surface` (because the `VirtualDisplay` resized). – EncodedNybble Feb 02 '16 at 01:27
  • fadden: Is it possible to keep using a Surface and, instead, modify SurfaceFlinger, libgui, libegl to do this? I was trying to drop frames at the BufferQueueProducer queueBuffer side but it causes havoc on the system – John Smith May 03 '16 at 07:15
  • @JohnSmith: it's probably easier (and more efficient) to drop frames at the source, but it should be possible to have SurfaceFlinger drop frames. There's a general problem where frames arrive "1, 2, 3... long pause... 4...", and you have to be sure not to drop frame 3 or things look weird during the pause. You need to hold frame #3 and send it after a timeout. This may be easier to manage at the source, rather than in SurfaceFlinger. You could probably do it inside BufferQueue but that code is not straightforward. – fadden May 03 '16 at 15:42
  • What's difference between MediaFormat.setInteger("frame-rate", int) and using OpenGL surface? – BuffK Jul 11 '20 at 09:45
2

You can reference the code sample from saki4510t's ScreenRecordingSample or RyanRQ's ScreenRecoder, they are all use the additional EGL Texture between the virtual display and media encoder, and the first one can keep at least 15 fps for the output video. You can search the keyword createVirtualDisplay from their code base for more details.

jason.chuang
  • 723
  • 1
  • 7
  • 10
0

You can control the frame rate of a virtual display by

  1. Creating an ImageReader.
  2. Getting the surface from an ImageReader and submitting it to the VirtualDisplay.
  3. On the ImageReader callback, acquire the latest Image frame, and store it.
  4. In another thread/event tick grab that runs at your desired frame rate, grab the latest frame from the intermedia variable.
  5. Take the surface of the encoder and grab the hardware canvas.
  6. Convert the Image to a Bitmap.
  7. Draw the Bitmap to the hardware canvas (optionally downscale or other drawing things you want), then unlock it.

You'll need to close images that aren't actually drawn, but this will be obvious as the application will crash if you don't do this but the stack trace will allow you to find the solution (along with the documentation) pretty fast.

This will work with recent versions of Android and is measured at around 11ms per frame. Most of the time spent will be in conversion of Image -> Bitmap which requires the pixels being downloaded from the GPU -> CPU, but then drawing will be done via GPU and very is fast.

If you are working with Android 31 and above you can replace steps 5-7 with an ImageWriter which will keep everything in the GPU. However prior to API 31 the ImageWriter will not be able to write to an encoder surface it expects pixel format RGB888 and the VirtualDisplay will produce images in pixel format RGBA8888 space and the work around for this was introduced in API 31.

Keep in mind that using pure EGL Surface's may be faster. But this works well enough to work at 30 fps. Multiple threads may be able to get it working at 60fps.

Here is some example Kotlin code that does this:

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.media.Image
import android.media.ImageReader
import android.view.Surface
import java.lang.Double.max
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

// An intermedia surface that downscales the input surface to the destination surface. Note that
// this is all done in software, so it is not very efficient and is measured at about 13ms per frame
// for the Samsung SM-S901U and ~13ms for Motorola Moto E6. However it is fast enough given that the
// capture rate is ~1 fps.
class SurfaceDownscaler(
    private val srcWidth: Int, private val srcHeight: Int,
    private val dstWidth: Int, private val dstHeight: Int,
    private val fps: Float) {

    companion object {
        private const val TAG = "SurfaceDownscaler"
        private const val LOG_PERFORMANCE = true
    }

    var inputSurface: Surface

    fun setDestinationSurface(surface: Surface?) {
        synchronized(surfaceLock) {
            surfaceDestination = surface
        }
    }

    fun pause() {
        paused.set(true)
    }
    fun resume() {
        paused.set(false)
    }

    private val paused = AtomicBoolean(false)
    private val surfaceLock = Any()
    private var surfaceDestination: Surface? = null
    private var imageFormat: Int = PixelFormat.RGBA_8888
    private var imageReader: ImageReader
    private val recentImage: AtomicReference<Image?> = AtomicReference(null)
    private val timerAllowImage = TimerIntervalThreaded(TAG, this::onTick)
    private lateinit var largeBitmap: Bitmap
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    private val imageListener: ImageReader.OnImageAvailableListener = ImageReader.OnImageAvailableListener {
        val image: Image? = it.acquireLatestImage()
        if (image == null) {
            return@OnImageAvailableListener
        }
        val prevImage: Image? = recentImage.getAndSet(image)
        if (prevImage != null) {
            prevImage.close()
        }
    }

    init {
        assert(srcWidth > 0)
        assert(srcHeight > 0)
        assert(dstWidth > 0)
        assert(dstHeight > 0)
        @SuppressLint("WrongConstant")
        imageReader = ImageReader.newInstance(srcWidth, srcHeight, imageFormat, 3)
        imageReader.setOnImageAvailableListener(imageListener, null)
        timerAllowImage.start()
        inputSurface = imageReader.surface
        paint.isFilterBitmap = true
    }

    fun close() {
        timerAllowImage.stop()
        imageReader.close()
        recentImage.getAndSet(null)?.close()
        if (::largeBitmap.isInitialized) {
            largeBitmap.recycle()
        }
    }


    private fun drawImageAndRelease(image: Image) {
        val startTime = System.currentTimeMillis()
        val planes = image.planes
        val pixelStride = planes[0].pixelStride
        val rowStride = planes[0].rowStride
        val rowPadding: Int = rowStride - pixelStride * image.width
        if (!::largeBitmap.isInitialized) {
            largeBitmap = Bitmap.createBitmap(
                srcWidth + rowPadding / pixelStride, srcHeight,
                Bitmap.Config.ARGB_8888
            )
        }
        largeBitmap.copyPixelsFromBuffer(planes[0].buffer)
        image.close()
        synchronized(surfaceLock) {
            val surface = surfaceDestination
            if (surface != null) {
                val canvas = surface.lockHardwareCanvas()
                val dstRect = Rect(0, 0, dstWidth, dstHeight)
                canvas.drawBitmap(largeBitmap, null, dstRect, paint)
                surface.unlockCanvasAndPost(canvas)
            }
        }
        val duration = System.currentTimeMillis() - startTime
        if (LOG_PERFORMANCE) {
            Log.d(TAG, "drawImageAndRelease duration: $duration")
        }
    }

    private fun onTick(): Long {
        val startTimeAll = System.currentTimeMillis()
        val image = recentImage.getAndSet(null)
        if (image != null) {
            if (paused.get()) {
                image.close()
            } else {
                drawImageAndRelease(image)
            }
        }
        val endTime = System.currentTimeMillis()
        val duration = endTime - startTimeAll
        val sleepTime = max(0.0, (1000.0 / fps) - duration.toDouble())
        if (LOG_PERFORMANCE) {
            Log.d(TAG, "duration: $duration")
        }
        return sleepTime.toLong()
    }

}
Zachary Vorhies
  • 521
  • 4
  • 4