Fix issue 514 (#528)
* Simplify SnapshotGlPictureRecorder * Add size * Remove setDefaultBufferSize * Call onStart at each frame * Add hasOverlay boolean * Use workaround in SnapshotVideoRecorder * Add issue description * Small changes * Move workaround.start() * use glBindTexture instead of workaround * Remove bindTexture from EglViewport * Simplify workaround * Reuse fallback WorkerHandler * Draw overlays on the encoder thread * Use lock instead of afterOverlayGlDrawn * Improve comments and readability * Move blending code to OverlayDrawer * Add OverlayDrawer tests * Improve WorkerHandler testspull/531/head
parent
3e0ae65dad
commit
42de6e30a4
@ -0,0 +1,120 @@ |
||||
package com.otaliastudios.cameraview.overlay; |
||||
|
||||
|
||||
import android.content.res.XmlResourceParser; |
||||
import android.graphics.Canvas; |
||||
import android.util.AttributeSet; |
||||
import android.util.Xml; |
||||
import android.view.View; |
||||
import android.view.ViewGroup; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.test.annotation.UiThreadTest; |
||||
import androidx.test.ext.junit.runners.AndroidJUnit4; |
||||
import androidx.test.filters.SmallTest; |
||||
|
||||
import com.otaliastudios.cameraview.BaseTest; |
||||
import com.otaliastudios.cameraview.internal.egl.EglBaseSurface; |
||||
import com.otaliastudios.cameraview.internal.egl.EglCore; |
||||
import com.otaliastudios.cameraview.internal.egl.EglViewport; |
||||
import com.otaliastudios.cameraview.size.Size; |
||||
|
||||
import org.hamcrest.BaseMatcher; |
||||
import org.hamcrest.Description; |
||||
import org.hamcrest.Matcher; |
||||
import org.junit.After; |
||||
import org.junit.Before; |
||||
import org.junit.Test; |
||||
import org.junit.runner.RunWith; |
||||
import org.mockito.Mockito; |
||||
|
||||
import static org.junit.Assert.assertFalse; |
||||
import static org.junit.Assert.assertThat; |
||||
import static org.junit.Assert.assertTrue; |
||||
import static org.mockito.ArgumentMatchers.any; |
||||
import static org.mockito.ArgumentMatchers.anyFloat; |
||||
import static org.mockito.ArgumentMatchers.anyLong; |
||||
import static org.mockito.ArgumentMatchers.eq; |
||||
import static org.mockito.Mockito.mock; |
||||
import static org.mockito.Mockito.never; |
||||
import static org.mockito.Mockito.reset; |
||||
import static org.mockito.Mockito.spy; |
||||
import static org.mockito.Mockito.times; |
||||
import static org.mockito.Mockito.verify; |
||||
import static org.mockito.Mockito.when; |
||||
|
||||
@RunWith(AndroidJUnit4.class) |
||||
@SmallTest |
||||
public class OverlayDrawerTest extends BaseTest { |
||||
|
||||
private final static int WIDTH = 100; |
||||
private final static int HEIGHT = 100; |
||||
|
||||
private EglCore eglCore; |
||||
private EglBaseSurface eglSurface; |
||||
|
||||
@Before |
||||
public void setUp() { |
||||
eglCore = new EglCore(null, EglCore.FLAG_RECORDABLE); |
||||
eglSurface = new EglBaseSurface(eglCore); |
||||
eglSurface.createOffscreenSurface(WIDTH, HEIGHT); |
||||
eglSurface.makeCurrent(); |
||||
} |
||||
|
||||
@After |
||||
public void tearDown() { |
||||
eglSurface.releaseEglSurface(); |
||||
eglSurface = null; |
||||
eglCore.release(); |
||||
eglCore = null; |
||||
} |
||||
|
||||
@Test |
||||
public void testDraw() { |
||||
Overlay overlay = mock(Overlay.class); |
||||
OverlayDrawer drawer = new OverlayDrawer(overlay, new Size(WIDTH, HEIGHT)); |
||||
drawer.draw(Overlay.Target.PICTURE_SNAPSHOT); |
||||
verify(overlay, times(1)).drawOn( |
||||
eq(Overlay.Target.PICTURE_SNAPSHOT), |
||||
any(Canvas.class)); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetTransform() { |
||||
// We'll check that the transform is not all zeros, which is highly unlikely
|
||||
// (the default transform should be the identity matrix)
|
||||
OverlayDrawer drawer = new OverlayDrawer(mock(Overlay.class), new Size(WIDTH, HEIGHT)); |
||||
drawer.draw(Overlay.Target.PICTURE_SNAPSHOT); |
||||
assertThat(drawer.getTransform(), new BaseMatcher<float[]>() { |
||||
public void describeTo(Description description) { } |
||||
public boolean matches(Object item) { |
||||
float[] array = (float[]) item; |
||||
for (float value : array) { |
||||
if (value != 0.0F) return true; |
||||
} |
||||
return false; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void testRender() { |
||||
OverlayDrawer drawer = new OverlayDrawer(mock(Overlay.class), new Size(WIDTH, HEIGHT)); |
||||
drawer.mViewport = spy(drawer.mViewport); |
||||
drawer.draw(Overlay.Target.PICTURE_SNAPSHOT); |
||||
drawer.render(); |
||||
verify(drawer.mViewport, times(1)).drawFrame( |
||||
drawer.mTextureId, |
||||
drawer.getTransform() |
||||
); |
||||
} |
||||
|
||||
@Test |
||||
public void testRelease() { |
||||
OverlayDrawer drawer = new OverlayDrawer(mock(Overlay.class), new Size(WIDTH, HEIGHT)); |
||||
EglViewport viewport = spy(drawer.mViewport); |
||||
drawer.mViewport = viewport; |
||||
drawer.release(); |
||||
verify(viewport, times(1)).release(); |
||||
} |
||||
} |
@ -0,0 +1,119 @@ |
||||
package com.otaliastudios.cameraview.internal; |
||||
|
||||
import android.graphics.Canvas; |
||||
import android.graphics.Color; |
||||
import android.graphics.SurfaceTexture; |
||||
import android.opengl.GLES11Ext; |
||||
import android.opengl.GLES20; |
||||
import android.view.Surface; |
||||
import com.otaliastudios.cameraview.internal.egl.EglViewport; |
||||
import com.otaliastudios.cameraview.preview.RendererThread; |
||||
|
||||
|
||||
/** |
||||
* Fixes an issue for some devices with snapshot picture and video recording. |
||||
* This is so unclear that I wanted to have a separate class holding code and comments. |
||||
* |
||||
* WHEN TO USE THIS CLASS |
||||
* There is actually no need of this class in some cases: |
||||
* - when we don't have overlays, everything works |
||||
* - on the majority of devices, everything works |
||||
* But some devices will show the issue #514 and so they need this class to fix it. |
||||
* We will use this always since it should have close to none performance impact. |
||||
* |
||||
* SNAPSHOT PROCEDURE |
||||
* The issue is about picture and video snapshots with overlays. In both cases, we: |
||||
* 1. Take textureId from the camera preview |
||||
* 2. Take EGLContext from the camera preview thread ({@link RendererThread}) |
||||
* 3. Create an overlayTextureId |
||||
* 4. Create an overlaySurfaceTexture |
||||
* 5. Create an overlaySurface |
||||
* 6. Move to another thread |
||||
* 7. Create a new EGLContext using the old context as a shared context so we have texture data |
||||
* 8. Create a new EGLWindow using some surface as output |
||||
* 9. For each frame: |
||||
* 9A. Draw overlays on the overlaySurface.lockCanvas() / unlockCanvasAndPost() |
||||
* 9B. Publish overlays to GL texture using overlaySurfaceTexture.updateTexImage() |
||||
* 9C. GLES - draw textureId |
||||
* 9D. GLES - draw overlayTextureId |
||||
* Both textures are drawn on the same EGLWindow and we manage to overlay them with {@link GLES20#GL_BLEND}. |
||||
* This is the whole procedure and it works for the majority of devices and situations. |
||||
* |
||||
* ISSUE DESCRIPTION |
||||
* The #514 issue can be described as follows: |
||||
* - Overlays have no transparency: background is {@link Color#BLACK} and covers the video |
||||
* - Overlays have distorted colors: {@link Color#RED} becomes greenish, |
||||
* {@link Color#GREEN} becomes blueish, |
||||
* {@link Color#BLUE} becomes reddish |
||||
* |
||||
* ISSUE INSIGHTS |
||||
* After painful debugging, we have reached these conclusions: |
||||
* 1. Overlays are drawn on {@link Canvas} with the correct format |
||||
* This can be checked for example by applying alpha to one overlay. The final color will |
||||
* be faded out, although on a black background. So the {@link Canvas} drawing step works well. |
||||
* 2. The GLES shader will always receive pixels in RGBA |
||||
* This seems to be a constant in Android - someone does the conversion for us at a lower level. |
||||
* This was confirmed for example by forcing A=0.5 and seeing the video frames behind the overlay |
||||
* black background, or by forcing to 0.0 some of the channels and seeing the output. |
||||
* 3. The {@link Canvas} / {@link Surface} pixels are wrongly treated as YUV! |
||||
* On problematic devices, some component down there thinks that our overlays RGBA are in YUV, |
||||
* and will CONVERT THEM TO RGBA. This means: |
||||
* 3A. Original alpha is dropped. The algorithm thinks we have passed YUV. |
||||
* 3B. Original colors are messed up. For example, (255,0,0,255,RGBA) is treated as (255,0,0,YUV) |
||||
* and converted back to rgb becoming greenish (74,255,27,255,RGBA). |
||||
* Doing the same conversion for {@link Color#GREEN} and {@link Color#BLUE} confirms what we |
||||
* were seeing in the issue screenshots. |
||||
* |
||||
* So a pixel format conversion takes place, when it shouldn't happen. We can't solve this: |
||||
* - It is done at a lower level, there's no real way for us to specify the surface format, but |
||||
* it seems that these devices will prefer a YUV format and misunderstand our {@link Canvas} pixels. |
||||
* - There is also no way to identify which devices will present this issue, it's a bug somewhere |
||||
* and it is implementation specific. |
||||
* |
||||
* THE MAGIC |
||||
* Hard to say why, but using this class fixes the described issue. |
||||
* It seems that when the {@link SurfaceTexture#updateTexImage()} method for the overlay surface |
||||
* is called - the one that updates the overlayTextureId - we must ensure that the CURRENTLY |
||||
* BOUND TEXTURE ID IS NOT 0. The id we choose to apply might be cameraTextureId, or overlayTextureId, |
||||
* or probably whatever other valid id, and should be passed to {@link #Issue514Workaround(int)}. |
||||
* [Tested with cameraTextureId and overlayTextureId: both do work.] |
||||
* [Tested with invalid id like 9999. This won't work.] |
||||
* |
||||
* This makes no sense, since overlaySurfaceTexture.updateTexImage() is setting it to overlayTextureId |
||||
* anyway, but it fixes the issue. Specifically, after any draw operation with {@link EglViewport}, |
||||
* the bound texture is reset to 0 so this must be undone here. We offer: |
||||
* |
||||
* - {@link #beforeOverlayUpdateTexImage()} to be called before the {@link SurfaceTexture#updateTexImage()} call |
||||
* - {@link #end()} to release and bring things back to normal state |
||||
* |
||||
* Since updating and rendering can happen on different threads with a shared EGL context, |
||||
* in case they do, the {@link #beforeOverlayUpdateTexImage()}, the actual updateTexImage() and |
||||
* finally the {@link EglViewport} drawing operations should be synchronized with a lock. |
||||
* |
||||
* REFERENCES |
||||
* https://github.com/natario1/CameraView/issues/514
|
||||
* https://android.googlesource.com/platform/frameworks/native/+/5c1139f/libs/gui/SurfaceTexture.cpp
|
||||
* I can see here that SurfaceTexture does indeed call glBindTexture with the same parameters whenever |
||||
* updateTexImage is called, but it also does other gl stuff first. This other gl stuff might be |
||||
* breaking when we don't have a bound texture on some specific hardware implementation. |
||||
*/ |
||||
public class Issue514Workaround { |
||||
|
||||
private final int textureId; |
||||
|
||||
public Issue514Workaround(int textureId) { |
||||
this.textureId = textureId; |
||||
} |
||||
|
||||
public void beforeOverlayUpdateTexImage() { |
||||
bindTexture(textureId); |
||||
} |
||||
|
||||
public void end() { |
||||
bindTexture(0); |
||||
} |
||||
|
||||
private void bindTexture(int textureId) { |
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); |
||||
} |
||||
} |
@ -0,0 +1,131 @@ |
||||
package com.otaliastudios.cameraview.overlay; |
||||
|
||||
import android.graphics.Canvas; |
||||
import android.graphics.Color; |
||||
import android.graphics.PorterDuff; |
||||
import android.graphics.SurfaceTexture; |
||||
import android.opengl.GLES11Ext; |
||||
import android.opengl.GLES20; |
||||
import android.view.Surface; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.VisibleForTesting; |
||||
|
||||
import com.otaliastudios.cameraview.CameraLogger; |
||||
import com.otaliastudios.cameraview.internal.Issue514Workaround; |
||||
import com.otaliastudios.cameraview.internal.egl.EglViewport; |
||||
import com.otaliastudios.cameraview.size.Size; |
||||
|
||||
import java.nio.Buffer; |
||||
|
||||
|
||||
/** |
||||
* Draws overlays through {@link Overlay}. |
||||
* |
||||
* - Provides a {@link Canvas} to be passed to the Overlay |
||||
* - Lets the overlay draw there: {@link #draw(Overlay.Target)} |
||||
* - Renders this into the current EGL window: {@link #render()} |
||||
* - Applies the {@link Issue514Workaround} the correct way |
||||
* |
||||
* In the future we might want to use a different approach than {@link EglViewport}, |
||||
* {@link SurfaceTexture} and {@link GLES11Ext#GL_TEXTURE_EXTERNAL_OES}, |
||||
* for example by using a regular {@link GLES20#GL_TEXTURE_2D} that might |
||||
* be filled through {@link GLES20#glTexImage2D(int, int, int, int, int, int, int, int, Buffer)}. |
||||
* |
||||
* The current approach has some issues, for example see {@link Issue514Workaround}. |
||||
*/ |
||||
public class OverlayDrawer { |
||||
|
||||
private static final String TAG = OverlayDrawer.class.getSimpleName(); |
||||
private static final CameraLogger LOG = CameraLogger.create(TAG); |
||||
|
||||
private Overlay mOverlay; |
||||
@VisibleForTesting int mTextureId; |
||||
private SurfaceTexture mSurfaceTexture; |
||||
private Surface mSurface; |
||||
private float[] mTransform = new float[16]; |
||||
@VisibleForTesting EglViewport mViewport; |
||||
private Issue514Workaround mIssue514Workaround; |
||||
private final Object mIssue514WorkaroundLock = new Object(); |
||||
|
||||
public OverlayDrawer(@NonNull Overlay overlay, @NonNull Size size) { |
||||
mOverlay = overlay; |
||||
mViewport = new EglViewport(); |
||||
mTextureId = mViewport.createTexture(); |
||||
mSurfaceTexture = new SurfaceTexture(mTextureId); |
||||
mSurfaceTexture.setDefaultBufferSize(size.getWidth(), size.getHeight()); |
||||
mSurface = new Surface(mSurfaceTexture); |
||||
mIssue514Workaround = new Issue514Workaround(mTextureId); |
||||
} |
||||
|
||||
/** |
||||
* Should be called to draw the {@link Overlay} on the given {@link Overlay.Target}. |
||||
* This will provide a working {@link Canvas} to the overlay and also update the |
||||
* drawn contents to a GLES texture. |
||||
* @param target the target |
||||
*/ |
||||
public void draw(@NonNull Overlay.Target target) { |
||||
try { |
||||
final Canvas surfaceCanvas = mSurface.lockCanvas(null); |
||||
surfaceCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); |
||||
mOverlay.drawOn(target, surfaceCanvas); |
||||
mSurface.unlockCanvasAndPost(surfaceCanvas); |
||||
} catch (Surface.OutOfResourcesException e) { |
||||
LOG.w("Got Surface.OutOfResourcesException while drawing video overlays", e); |
||||
} |
||||
synchronized (mIssue514WorkaroundLock) { |
||||
mIssue514Workaround.beforeOverlayUpdateTexImage(); |
||||
mSurfaceTexture.updateTexImage(); |
||||
} |
||||
mSurfaceTexture.getTransformMatrix(mTransform); |
||||
} |
||||
|
||||
/** |
||||
* Returns the transform that should be used to render the drawn content. |
||||
* This should be called after {@link #draw(Overlay.Target)} and can be modified. |
||||
* @return the transform matrix |
||||
*/ |
||||
public float[] getTransform() { |
||||
return mTransform; |
||||
} |
||||
|
||||
/** |
||||
* Renders the drawn content in the current EGL surface, assuming there is one. |
||||
* Should be called after {@link #draw(Overlay.Target)} and any {@link #getTransform()} |
||||
* modification. |
||||
*/ |
||||
public void render() { |
||||
// Enable blending
|
||||
// Reference http://www.learnopengles.com/android-lesson-five-an-introduction-to-blending/
|
||||
GLES20.glDisable(GLES20.GL_CULL_FACE); |
||||
GLES20.glDisable(GLES20.GL_DEPTH_TEST); |
||||
GLES20.glEnable(GLES20.GL_BLEND); |
||||
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA); |
||||
|
||||
synchronized (mIssue514WorkaroundLock) { |
||||
mViewport.drawFrame(mTextureId, mTransform); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Releases resources. |
||||
*/ |
||||
public void release() { |
||||
if (mIssue514Workaround != null) { |
||||
mIssue514Workaround.end(); |
||||
mIssue514Workaround = null; |
||||
} |
||||
if (mSurfaceTexture != null) { |
||||
mSurfaceTexture.release(); |
||||
mSurfaceTexture = null; |
||||
} |
||||
if (mSurface != null) { |
||||
mSurface.release(); |
||||
mSurface = null; |
||||
} |
||||
if (mViewport != null) { |
||||
mViewport.release(); |
||||
mViewport = null; |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue