MultiFilter to merge filters (#559)

* Ensure copied filters have right size

* Create MultiFilter base implementation

* Working implementation with replaceAll hack

* Implement OneParameterFilter and TwoParameterFilter in MultiFilter

* Add docs

* Add tests

* Fix rotation issues

* Fix tests and cleanup

* Small improvements
pull/565/head
Mattia Iavarone 5 years ago committed by GitHub
parent 83307c527e
commit d333348ce6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 213
      cameraview/src/androidTest/java/com/otaliastudios/cameraview/filter/MultiFilterTest.java
  2. 325
      cameraview/src/main/java/com/otaliastudios/cameraview/filter/MultiFilter.java
  3. 16
      cameraview/src/main/java/com/otaliastudios/cameraview/internal/egl/EglViewport.java
  4. 20
      cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotGlPictureRecorder.java
  5. 32
      cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java
  6. 9
      cameraview/src/main/java/com/otaliastudios/cameraview/preview/RendererFrameCallback.java
  7. 8
      cameraview/src/main/java/com/otaliastudios/cameraview/video/SnapshotVideoRecorder.java
  8. 153
      cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/TextureMediaEncoder.java
  9. 9
      demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java
  10. 21
      docs/_posts/2019-08-06-filters.md

@ -0,0 +1,213 @@
package com.otaliastudios.cameraview.filter;
import android.opengl.GLES20;
import androidx.annotation.NonNull;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;
import com.otaliastudios.cameraview.BaseEglTest;
import com.otaliastudios.cameraview.filters.AutoFixFilter;
import com.otaliastudios.cameraview.filters.BrightnessFilter;
import com.otaliastudios.cameraview.filters.DuotoneFilter;
import com.otaliastudios.cameraview.filters.VignetteFilter;
import com.otaliastudios.cameraview.internal.GlUtils;
import com.otaliastudios.cameraview.internal.egl.EglViewport;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
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 MultiFilterTest extends BaseEglTest {
@Test
public void testConstructor1() {
MultiFilter multiFilter = new MultiFilter(
new DuotoneFilter(),
new AutoFixFilter()
);
assertEquals(2, multiFilter.filters.size());
}
@Test
public void testConstructor2() {
List<Filter> filters = new ArrayList<>();
filters.add(new DuotoneFilter());
filters.add(new AutoFixFilter());
MultiFilter multiFilter = new MultiFilter(filters);
assertEquals(2, multiFilter.filters.size());
}
@Test
public void testAddFilter() {
MultiFilter multiFilter = new MultiFilter();
assertEquals(0, multiFilter.filters.size());
multiFilter.addFilter(new DuotoneFilter());
assertEquals(1, multiFilter.filters.size());
multiFilter.addFilter(new AutoFixFilter());
assertEquals(2, multiFilter.filters.size());
}
@Test
public void testAddFilter_multi() {
MultiFilter multiFilter = new MultiFilter(new DuotoneFilter());
assertEquals(1, multiFilter.filters.size());
MultiFilter other = new MultiFilter(
new AutoFixFilter(),
new BrightnessFilter(),
new VignetteFilter());
multiFilter.addFilter(other);
assertEquals(4, multiFilter.filters.size());
}
@Test
public void testSetSize() {
DuotoneFilter filter = new DuotoneFilter();
MultiFilter multiFilter = new MultiFilter(filter);
MultiFilter.State state = multiFilter.states.get(filter);
assertNotNull(state);
assertNull(state.size);
multiFilter.setSize(WIDTH, HEIGHT);
assertNotNull(state.size);
}
@Test
public void testCopy() {
DuotoneFilter filter = spy(new DuotoneFilter());
MultiFilter multiFilter = new MultiFilter(filter);
MultiFilter multiFilterCopy = (MultiFilter) multiFilter.copy();
assertEquals(1, multiFilterCopy.filters.size());
verify(filter, times(1)).onCopy();
}
@Test
public void testParameter1() {
DuotoneFilter filter = new DuotoneFilter();
MultiFilter multiFilter = new MultiFilter(filter);
float desired = 0.21582F; // whatever
multiFilter.setParameter1(desired);
assertEquals(desired, multiFilter.getParameter1(), 0.001F);
assertEquals(desired, filter.getParameter1(), 0.001F);
}
@Test
public void testParameter2() {
DuotoneFilter filter = new DuotoneFilter();
MultiFilter multiFilter = new MultiFilter(filter);
float desired = 0.21582F; // whatever
multiFilter.setParameter2(desired);
assertEquals(desired, multiFilter.getParameter2(), 0.001F);
assertEquals(desired, filter.getParameter2(), 0.001F);
}
@Test
public void testOnCreate_isLazy() {
DuotoneFilter filter = spy(new DuotoneFilter());
MultiFilter multiFilter = new MultiFilter(filter);
int program = GlUtils.createProgram(multiFilter.getVertexShader(),
multiFilter.getFragmentShader());
multiFilter.onCreate(program);
verify(filter, never()).onCreate(anyInt());
multiFilter.onDestroy();
GLES20.glDeleteProgram(program);
verify(filter, never()).onDestroy();
}
@Test
public void testDraw_simple() {
DuotoneFilter filter = spy(new DuotoneFilter());
MultiFilter multiFilter = new MultiFilter(filter);
multiFilter.setSize(WIDTH, HEIGHT);
EglViewport viewport = new EglViewport(multiFilter);
int texture = viewport.createTexture();
float[] matrix = new float[16];
viewport.drawFrame(texture, matrix);
viewport.release();
// The child should have experienced the whole lifecycle.
verify(filter, atLeastOnce()).getVertexShader();
verify(filter, atLeastOnce()).getFragmentShader();
verify(filter, atLeastOnce()).setSize(anyInt(), anyInt());
verify(filter, times(1)).onCreate(anyInt());
verify(filter, times(1)).draw(matrix);
verify(filter, times(1)).onDestroy();
}
@Test
public void testDraw_multi() {
// Want to test that when filter1 is drawn, the current framebuffer is a
// non-default one. When filter2 is drawn, the current framebuffer is 0.
final DuotoneFilter filter1 = spy(new DuotoneFilter());
final DuotoneFilter filter2 = spy(new DuotoneFilter());
final MultiFilter multiFilter = new MultiFilter(filter1, filter2);
multiFilter.setSize(WIDTH, HEIGHT);
float[] matrix = new float[16];
final int[] result = new int[1];
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) {
MultiFilter.State state = multiFilter.states.get(filter1);
assertNotNull(state);
assertTrue(state.isCreated);
assertTrue(state.isFramebufferCreated);
GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, result, 0);
assertTrue(result[0] != 0);
return null;
}
}).when(filter1).draw(matrix);
// Note: second filter is drawn with the identity matrix!
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) {
// The last filter has no FBO / texture.
MultiFilter.State state = multiFilter.states.get(filter2);
assertNotNull(state);
assertTrue(state.isCreated);
assertFalse(state.isFramebufferCreated);
GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, result, 0);
assertEquals(0, result[0]);
return null;
}
}).when(filter2).draw(any(float[].class));
EglViewport viewport = new EglViewport(multiFilter);
int texture = viewport.createTexture();
viewport.drawFrame(texture, matrix);
viewport.release();
// Verify that both are drawn.
verify(filter1, times(1)).draw(matrix);
verify(filter2, times(1)).draw(any(float[].class));
}
}

@ -0,0 +1,325 @@
package com.otaliastudios.cameraview.filter;
import android.opengl.GLES11Ext;
import android.opengl.GLES20;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.otaliastudios.cameraview.internal.GlUtils;
import com.otaliastudios.cameraview.size.Size;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A {@link MultiFilter} is a special {@link Filter} that can group one or more filters together.
* When this happens, filters are applied in sequence:
* - the first filter reads from input frames
* - the second filters reads the output of the first
* And so on, until the last filter which will read from the previous and write to the real output.
*
* New filters can be added at any time through {@link #addFilter(Filter)}, but currently they
* can not be removed because we can not easily ensure that they would be correctly released.
*
* The {@link MultiFilter} does also implement {@link OneParameterFilter} and {@link TwoParameterFilter},
* dispatching all the parameter calls to child filters, assuming they support it.
*
* There are some important technical caveats when using {@link MultiFilter}:
* - each child filter requires the allocation of a GL framebuffer. Using a large number of filters
* will likely cause memory issues (e.g. https://stackoverflow.com/q/6354208/4288782).
* - some of the children need to write into {@link GLES20#GL_TEXTURE_2D} instead of
* {@link GLES11Ext#GL_TEXTURE_EXTERNAL_OES}! To achieve this, we replace samplerExternalOES
* with sampler2D in your fragment shader code. This might cause issues for some shaders.
*/
@SuppressWarnings("unused")
public class MultiFilter implements Filter, OneParameterFilter, TwoParameterFilter {
@VisibleForTesting
static class State {
@VisibleForTesting boolean isCreated = false;
@VisibleForTesting boolean isFramebufferCreated = false;
@VisibleForTesting Size size = null;
private int programHandle = -1;
private int framebufferId = -1;
private int textureId = -1;
}
@VisibleForTesting final List<Filter> filters = new ArrayList<>();
@VisibleForTesting final Map<Filter, State> states = new HashMap<>();
private final Object lock = new Object();
private Size size = null;
private float parameter1 = 0F;
private float parameter2 = 0F;
/**
* Creates a new group with the given filters.
* @param filters children
*/
@SuppressWarnings("WeakerAccess")
public MultiFilter(@NonNull Filter... filters) {
this(Arrays.asList(filters));
}
/**
* Creates a new group with the given filters.
* @param filters children
*/
@SuppressWarnings("WeakerAccess")
public MultiFilter(@NonNull Collection<Filter> filters) {
for (Filter filter : filters) {
addFilter(filter);
}
}
/**
* Adds a new filter. It will be used in the next frame.
* If the filter is a {@link MultiFilter}, we'll use its children instead.
*
* @param filter a new filter
*/
@SuppressWarnings("WeakerAccess")
public void addFilter(@NonNull Filter filter) {
if (filter instanceof MultiFilter) {
MultiFilter multiFilter = (MultiFilter) filter;
for (Filter multiChild : multiFilter.filters) {
addFilter(multiChild);
}
return;
}
synchronized (lock) {
if (!filters.contains(filter)) {
filters.add(filter);
states.put(filter, new State());
}
}
}
// We don't offer a removeFilter method since that would cause issues
// with cleanup. Cleanup must happen on the GL thread so we'd have to wait
// for new rendering call (which might not even happen).
private void maybeCreate(@NonNull Filter filter, boolean isFirst) {
State state = states.get(filter);
//noinspection ConstantConditions
if (!state.isCreated) {
state.isCreated = true;
String shader = filter.getFragmentShader();
if (!isFirst) {
// The first shader actually reads from a OES texture, but the others
// will read from the 2d framebuffer texture. This is a dirty hack.
shader = shader.replace("samplerExternalOES ", "sampler2D ");
}
state.programHandle = GlUtils.createProgram(filter.getVertexShader(), shader);
filter.onCreate(state.programHandle);
}
}
private void maybeDestroy(@NonNull Filter filter) {
State state = states.get(filter);
//noinspection ConstantConditions
if (state.isCreated) {
state.isCreated = false;
filter.onDestroy();
GLES20.glDeleteProgram(state.programHandle);
state.programHandle = -1;
}
}
private void maybeCreateFramebuffer(@NonNull Filter filter, boolean isLast) {
if (isLast) return;
State state = states.get(filter);
//noinspection ConstantConditions
if (!state.isFramebufferCreated) {
state.isFramebufferCreated = true;
int[] framebufferArray = new int[1];
int[] textureArray = new int[1];
GLES20.glGenFramebuffers(1, framebufferArray, 0);
GLES20.glGenTextures(1, textureArray, 0);
state.framebufferId = framebufferArray[0];
state.textureId = textureArray[0];
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, state.textureId);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, state.size.getWidth(), state.size.getHeight(), 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, state.framebufferId);
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER,
GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D,
state.textureId,
0);
int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER);
if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
throw new RuntimeException("Invalid framebuffer generation. Error:" + status);
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
}
}
private void maybeDestroyFramebuffer(@NonNull Filter filter) {
State state = states.get(filter);
//noinspection ConstantConditions
if (state.isFramebufferCreated) {
state.isFramebufferCreated = false;
GLES20.glDeleteFramebuffers(1, new int[]{state.framebufferId}, 0);
state.framebufferId = -1;
GLES20.glDeleteTextures(1, new int[]{state.textureId}, 0);
state.textureId = -1;
}
}
private void maybeSetSize(@NonNull Filter filter) {
State state = states.get(filter);
//noinspection ConstantConditions
if (size != null && !size.equals(state.size)) {
state.size = size;
filter.setSize(size.getWidth(), size.getHeight());
}
}
@NonNull
@Override
public String getVertexShader() {
// Whatever, we won't be using this.
return new NoFilter().getVertexShader();
}
@NonNull
@Override
public String getFragmentShader() {
// Whatever, we won't be using this.
return new NoFilter().getFragmentShader();
}
@Override
public void onCreate(int programHandle) {
// We'll create children during the draw() op, since some of them
// might have been added after this onCreate() is called.
}
@Override
public void onDestroy() {
synchronized (lock) {
for (Filter filter : filters) {
maybeDestroyFramebuffer(filter);
maybeDestroy(filter);
}
}
}
@Override
public void setSize(int width, int height) {
size = new Size(width, height);
synchronized (lock) {
for (Filter filter : filters) {
maybeSetSize(filter);
}
}
}
@Override
public void draw(float[] transformMatrix) {
synchronized (lock) {
for (int i = 0; i < filters.size(); i++) {
boolean isFirst = i == 0;
boolean isLast = i == filters.size() - 1;
Filter filter = filters.get(i);
State state = states.get(filter);
maybeSetSize(filter);
maybeCreate(filter, isFirst);
maybeCreateFramebuffer(filter, isLast);
//noinspection ConstantConditions
GLES20.glUseProgram(state.programHandle);
// Define the output framebuffer.
// Each filter outputs into its own framebuffer object, except the
// last filter, which outputs into the default framebuffer.
if (!isLast) {
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, state.framebufferId);
GLES20.glClearColor(0, 0, 0, 0);
} else {
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
}
// Perform the actual drawing.
// The first filter should apply all the transformations. Then,
// since they are applied, we should use a no-op matrix.
if (isFirst) {
filter.draw(transformMatrix);
} else {
filter.draw(GlUtils.IDENTITY_MATRIX);
}
// Set the input for the next cycle:
// It is the framebuffer texture from this cycle. If this is the last
// filter, reset this value just to cleanup.
if (!isLast) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, state.textureId);
} else {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
}
GLES20.glUseProgram(0);
}
}
}
@NonNull
@Override
public Filter copy() {
synchronized (lock) {
MultiFilter copy = new MultiFilter();
for (Filter filter : filters) {
copy.addFilter(filter.copy());
}
return copy;
}
}
@Override
public void setParameter1(float parameter1) {
this.parameter1 = parameter1;
synchronized (lock) {
for (Filter filter : filters) {
if (filter instanceof OneParameterFilter) {
((OneParameterFilter) filter).setParameter1(parameter1);
}
}
}
}
@Override
public void setParameter2(float parameter2) {
this.parameter2 = parameter2;
synchronized (lock) {
for (Filter filter : filters) {
if (filter instanceof TwoParameterFilter) {
((TwoParameterFilter) filter).setParameter2(parameter2);
}
}
}
}
@Override
public float getParameter1() {
return parameter1;
}
@Override
public float getParameter2() {
return parameter2;
}
}

@ -21,7 +21,7 @@ public class EglViewport {
private int mTextureUnit; private int mTextureUnit;
private Filter mFilter; private Filter mFilter;
private boolean mFilterChanged = false; private Filter mPendingFilter;
public EglViewport() { public EglViewport() {
this(new NoFilter()); this(new NoFilter());
@ -35,7 +35,6 @@ public class EglViewport {
} }
private void createProgram() { private void createProgram() {
release(); // Release old program if present.
mProgramHandle = GlUtils.createProgram(mFilter.getVertexShader(), mFilter.getFragmentShader()); mProgramHandle = GlUtils.createProgram(mFilter.getVertexShader(), mFilter.getFragmentShader());
mFilter.onCreate(mProgramHandle); mFilter.onCreate(mProgramHandle);
} }
@ -67,15 +66,18 @@ public class EglViewport {
return texId; return texId;
} }
public void setFilter(@NonNull Filter filter){ public void setFilter(@NonNull Filter filter) {
this.mFilter = filter; // TODO see if this is needed. If setFilter is always called from the correct GL thread,
mFilterChanged = true; // we don't need to wait for a new draw call (which might not even happen).
mPendingFilter = filter;
} }
public void drawFrame(int textureId, float[] textureMatrix) { public void drawFrame(int textureId, float[] textureMatrix) {
if (mFilterChanged) { if (mPendingFilter != null) {
release();
mFilter = mPendingFilter;
mPendingFilter = null;
createProgram(); createProgram();
mFilterChanged = false;
} }
GlUtils.checkError("draw start"); GlUtils.checkError("draw start");

@ -95,6 +95,12 @@ public class SnapshotGlPictureRecorder extends PictureRecorder {
SnapshotGlPictureRecorder.this.onRendererTextureCreated(textureId); SnapshotGlPictureRecorder.this.onRendererTextureCreated(textureId);
} }
@RendererThread
@Override
public void onRendererFilterChanged(@NonNull Filter filter) {
SnapshotGlPictureRecorder.this.onRendererFilterChanged(filter);
}
@RendererThread @RendererThread
@Override @Override
public void onRendererFrame(@NonNull SurfaceTexture surfaceTexture, final float scaleX, final float scaleY) { public void onRendererFrame(@NonNull SurfaceTexture surfaceTexture, final float scaleX, final float scaleY) {
@ -102,10 +108,6 @@ public class SnapshotGlPictureRecorder extends PictureRecorder {
SnapshotGlPictureRecorder.this.onRendererFrame(surfaceTexture, scaleX, scaleY); SnapshotGlPictureRecorder.this.onRendererFrame(surfaceTexture, scaleX, scaleY);
} }
@Override
public void onFilterChanged(@NonNull Filter filter) {
mViewport.setFilter(filter.copy());
}
}); });
} }
@ -125,6 +127,12 @@ public class SnapshotGlPictureRecorder extends PictureRecorder {
} }
} }
@RendererThread
@TargetApi(Build.VERSION_CODES.KITKAT)
private void onRendererFilterChanged(@NonNull Filter filter) {
mViewport.setFilter(filter.copy());
}
/** /**
* The tricky part here is the EGL surface creation. * The tricky part here is the EGL surface creation.
* *
@ -150,7 +158,9 @@ public class SnapshotGlPictureRecorder extends PictureRecorder {
*/ */
@RendererThread @RendererThread
@TargetApi(Build.VERSION_CODES.KITKAT) @TargetApi(Build.VERSION_CODES.KITKAT)
private void onRendererFrame(final @NonNull SurfaceTexture surfaceTexture, final float scaleX, final float scaleY) { private void onRendererFrame(@SuppressWarnings("unused") @NonNull SurfaceTexture surfaceTexture,
final float scaleX,
final float scaleY) {
// Get egl context from the RendererThread, which is the one in which we have created // Get egl context from the RendererThread, which is the one in which we have created
// the textureId and the overlayTextureId, managed by the GlSurfaceView. // the textureId and the overlayTextureId, managed by the GlSurfaceView.
// Next operations can then be performed on different threads using this handle. // Next operations can then be performed on different threads using this handle.

@ -141,7 +141,9 @@ public class GlCameraPreview extends FilterCameraPreview<GLSurfaceView, SurfaceT
@RendererThread @RendererThread
@Override @Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) { public void onSurfaceCreated(GL10 gl, EGLConfig config) {
if (mCurrentFilter == null) mCurrentFilter = new NoFilter(); if (mCurrentFilter == null) {
mCurrentFilter = new NoFilter();
}
mOutputViewport = new EglViewport(mCurrentFilter); mOutputViewport = new EglViewport(mCurrentFilter);
mOutputTextureId = mOutputViewport.createTexture(); mOutputTextureId = mOutputViewport.createTexture();
mInputSurfaceTexture = new SurfaceTexture(mOutputTextureId); mInputSurfaceTexture = new SurfaceTexture(mOutputTextureId);
@ -282,7 +284,7 @@ public class GlCameraPreview extends FilterCameraPreview<GLSurfaceView, SurfaceT
public void run() { public void run() {
mRendererFrameCallbacks.add(callback); mRendererFrameCallbacks.add(callback);
if (mOutputTextureId != 0) callback.onRendererTextureCreated(mOutputTextureId); if (mOutputTextureId != 0) callback.onRendererTextureCreated(mOutputTextureId);
callback.onFilterChanged(mCurrentFilter); callback.onRendererFilterChanged(mCurrentFilter);
} }
}); });
} }
@ -325,20 +327,26 @@ public class GlCameraPreview extends FilterCameraPreview<GLSurfaceView, SurfaceT
} }
@Override @Override
public void setFilter(@NonNull Filter filter) { public void setFilter(final @NonNull Filter filter) {
mCurrentFilter = filter;
if (hasSurface()) { if (hasSurface()) {
filter.setSize(mOutputSurfaceWidth, mOutputSurfaceHeight); filter.setSize(mOutputSurfaceWidth, mOutputSurfaceHeight);
} }
mCurrentFilter = filter;
if (mOutputViewport != null) {
mOutputViewport.setFilter(filter);
}
// Need to synchronize when iterating the Collections.synchronizedSet getView().queueEvent(new Runnable() {
synchronized (mRendererFrameCallbacks) { @Override
for (RendererFrameCallback callback : mRendererFrameCallbacks) { public void run() {
callback.onFilterChanged(filter); if (mOutputViewport != null) {
mOutputViewport.setFilter(filter);
}
// Need to synchronize when iterating the Collections.synchronizedSet
synchronized (mRendererFrameCallbacks) {
for (RendererFrameCallback callback : mRendererFrameCallbacks) {
callback.onRendererFilterChanged(filter);
}
}
} }
} });
} }
} }

@ -33,10 +33,11 @@ public interface RendererFrameCallback {
void onRendererFrame(@NonNull SurfaceTexture surfaceTexture, float scaleX, float scaleY); void onRendererFrame(@NonNull SurfaceTexture surfaceTexture, float scaleX, float scaleY);
/** /**
* Called on the change of shader filter * Called when the renderer filter changes. This is guaranteed to be called at least once
* We should update the EglViewPort once you receives this event * before the first {@link #onRendererFrame(SurfaceTexture, float, float)}.
* *
* @param filter the new filter applied * @param filter the new filter
*/ */
void onFilterChanged(@NonNull Filter filter); @RendererThread
void onRendererFilterChanged(@NonNull Filter filter);
} }

@ -104,9 +104,10 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram
} }
@Override @Override
public void onFilterChanged(@NonNull Filter filter) { public void onRendererFilterChanged(@NonNull Filter filter) {
mCurrentFilter = filter.copy(); mCurrentFilter = filter.copy();
if (mEncoderEngine != null) { if (mEncoderEngine != null) {
mCurrentFilter.setSize(mResult.size.getWidth(), mResult.size.getHeight());
mEncoderEngine.notify(TextureMediaEncoder.FILTER_EVENT, mCurrentFilter); mEncoderEngine.notify(TextureMediaEncoder.FILTER_EVENT, mCurrentFilter);
} }
} }
@ -161,6 +162,10 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram
} }
TextureMediaEncoder videoEncoder = new TextureMediaEncoder(videoConfig); TextureMediaEncoder videoEncoder = new TextureMediaEncoder(videoConfig);
// Adjustment
mResult.rotation = 0; // We will rotate the result instead.
mCurrentFilter.setSize(mResult.size.getWidth(), mResult.size.getWidth());
// Audio // Audio
AudioMediaEncoder audioEncoder = null; AudioMediaEncoder audioEncoder = null;
if (mResult.audio == Audio.ON || mResult.audio == Audio.MONO || mResult.audio == Audio.STEREO) { if (mResult.audio == Audio.ON || mResult.audio == Audio.MONO || mResult.audio == Audio.STEREO) {
@ -181,7 +186,6 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram
SnapshotVideoRecorder.this); SnapshotVideoRecorder.this);
mEncoderEngine.notify(TextureMediaEncoder.FILTER_EVENT, mCurrentFilter); mEncoderEngine.notify(TextureMediaEncoder.FILTER_EVENT, mCurrentFilter);
mEncoderEngine.start(); mEncoderEngine.start();
mResult.rotation = 0; // We will rotate the result instead.
mCurrentState = STATE_RECORDING; mCurrentState = STATE_RECORDING;
} }

@ -134,85 +134,92 @@ public class TextureMediaEncoder extends VideoMediaEncoder<TextureConfig> {
@EncoderThread @EncoderThread
@Override @Override
protected void onEvent(@NonNull String event, @Nullable Object data) { protected void onEvent(@NonNull String event, @Nullable Object data) {
switch (event) {
case FILTER_EVENT:
//noinspection ConstantConditions
onFilter((Filter) data);
break;
case FRAME_EVENT:
//noinspection ConstantConditions
onFrame((Frame) data);
break;
}
}
if (event.equals(FILTER_EVENT)) { private void onFilter(@NonNull Filter filter) {
Filter filter = (Filter) data; mViewport.setFilter(filter);
mViewport.setFilter(filter); }
} else if (event.equals(FRAME_EVENT)) {
Frame frame = (Frame) data;
if (frame == null) {
throw new IllegalArgumentException("Got null frame for FRAME_EVENT.");
}
if (!shouldRenderFrame(frame.timestampUs())) {
mFramePool.recycle(frame);
return;
}
// Notify we're got the first frame and its absolute time. private void onFrame(@NonNull Frame frame) {
if (mFrameNumber == 1) { if (!shouldRenderFrame(frame.timestampUs())) {
notifyFirstFrameMillis(frame.timestampMillis); mFramePool.recycle(frame);
} return;
}
// Notify we have reached the max length value. // Notify we're got the first frame and its absolute time.
if (mFirstTimeUs == Long.MIN_VALUE) mFirstTimeUs = frame.timestampUs(); if (mFrameNumber == 1) {
if (!hasReachedMaxLength()) { notifyFirstFrameMillis(frame.timestampMillis);
boolean didReachMaxLength = (frame.timestampUs() - mFirstTimeUs) > getMaxLengthUs(); }
if (didReachMaxLength) {
LOG.w("onEvent -",
"frameNumber:", mFrameNumber,
"timestampUs:", frame.timestampUs(),
"firstTimeUs:", mFirstTimeUs,
"- reached max length! deltaUs:", frame.timestampUs() - mFirstTimeUs);
notifyMaxLengthReached();
}
}
// First, drain any previous data. // Notify we have reached the max length value.
LOG.i("onEvent -", if (mFirstTimeUs == Long.MIN_VALUE) mFirstTimeUs = frame.timestampUs();
"frameNumber:", mFrameNumber, if (!hasReachedMaxLength()) {
"timestampUs:", frame.timestampUs(), boolean didReachMaxLength = (frame.timestampUs() - mFirstTimeUs) > getMaxLengthUs();
"- draining."); if (didReachMaxLength) {
drainOutput(false); LOG.w("onEvent -",
"frameNumber:", mFrameNumber,
// Then draw on the surface. "timestampUs:", frame.timestampUs(),
LOG.i("onEvent -", "firstTimeUs:", mFirstTimeUs,
"frameNumber:", mFrameNumber, "- reached max length! deltaUs:", frame.timestampUs() - mFirstTimeUs);
"timestampUs:", frame.timestampUs(), notifyMaxLengthReached();
"- rendering.");
// 1. We must scale this matrix like GlCameraPreview does, because it might have some cropping.
// Scaling takes place with respect to the (0, 0, 0) point, so we must apply a Translation to compensate.
float[] transform = frame.transform;
float scaleX = mConfig.scaleX;
float scaleY = mConfig.scaleY;
float scaleTranslX = (1F - scaleX) / 2F;
float scaleTranslY = (1F - scaleY) / 2F;
Matrix.translateM(transform, 0, scaleTranslX, scaleTranslY, 0);
Matrix.scaleM(transform, 0, scaleX, scaleY, 1);
// 2. We also must rotate this matrix. In GlCameraPreview it is not needed because it is a live
// stream, but the output video, must be correctly rotated based on the device rotation at the moment.
// Rotation also takes place with respect to the origin (the Z axis), so we must
// translate to origin, rotate, then back to where we were.
Matrix.translateM(transform, 0, 0.5F, 0.5F, 0);
Matrix.rotateM(transform, 0, mTransformRotation, 0, 0, 1);
Matrix.translateM(transform, 0, -0.5F, -0.5F, 0);
// 3. Do the same for overlays with their own rotation.
if (mConfig.hasOverlay()) {
mConfig.overlayDrawer.draw(mConfig.overlayTarget);
Matrix.translateM(mConfig.overlayDrawer.getTransform(), 0, 0.5F, 0.5F, 0);
Matrix.rotateM(mConfig.overlayDrawer.getTransform(), 0, mConfig.overlayRotation, 0, 0, 1);
Matrix.translateM(mConfig.overlayDrawer.getTransform(), 0, -0.5F, -0.5F, 0);
} }
mViewport.drawFrame(mConfig.textureId, transform);
if (mConfig.hasOverlay()) {
mConfig.overlayDrawer.render();
}
mWindow.setPresentationTime(frame.timestampNanos);
mWindow.swapBuffers();
mFramePool.recycle(frame);
} }
// First, drain any previous data.
LOG.i("onEvent -",
"frameNumber:", mFrameNumber,
"timestampUs:", frame.timestampUs(),
"- draining.");
drainOutput(false);
// Then draw on the surface.
LOG.i("onEvent -",
"frameNumber:", mFrameNumber,
"timestampUs:", frame.timestampUs(),
"- rendering.");
// 1. We must scale this matrix like GlCameraPreview does, because it might have some cropping.
// Scaling takes place with respect to the (0, 0, 0) point, so we must apply a Translation to compensate.
float[] transform = frame.transform;
float scaleX = mConfig.scaleX;
float scaleY = mConfig.scaleY;
float scaleTranslX = (1F - scaleX) / 2F;
float scaleTranslY = (1F - scaleY) / 2F;
Matrix.translateM(transform, 0, scaleTranslX, scaleTranslY, 0);
Matrix.scaleM(transform, 0, scaleX, scaleY, 1);
// 2. We also must rotate this matrix. In GlCameraPreview it is not needed because it is a live
// stream, but the output video, must be correctly rotated based on the device rotation at the moment.
// Rotation also takes place with respect to the origin (the Z axis), so we must
// translate to origin, rotate, then back to where we were.
Matrix.translateM(transform, 0, 0.5F, 0.5F, 0);
Matrix.rotateM(transform, 0, mTransformRotation, 0, 0, 1);
Matrix.translateM(transform, 0, -0.5F, -0.5F, 0);
// 3. Do the same for overlays with their own rotation.
if (mConfig.hasOverlay()) {
mConfig.overlayDrawer.draw(mConfig.overlayTarget);
Matrix.translateM(mConfig.overlayDrawer.getTransform(), 0, 0.5F, 0.5F, 0);
Matrix.rotateM(mConfig.overlayDrawer.getTransform(), 0, mConfig.overlayRotation, 0, 0, 1);
Matrix.translateM(mConfig.overlayDrawer.getTransform(), 0, -0.5F, -0.5F, 0);
}
mViewport.drawFrame(mConfig.textureId, transform);
if (mConfig.hasOverlay()) {
mConfig.overlayDrawer.render();
}
mWindow.setPresentationTime(frame.timestampNanos);
mWindow.swapBuffers();
mFramePool.recycle(frame);
} }
@Override @Override

@ -5,6 +5,7 @@ import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.ImageFormat; import android.graphics.ImageFormat;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.YuvImage; import android.graphics.YuvImage;
@ -28,7 +29,9 @@ import com.otaliastudios.cameraview.controls.Mode;
import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.VideoResult;
import com.otaliastudios.cameraview.controls.Preview; import com.otaliastudios.cameraview.controls.Preview;
import com.otaliastudios.cameraview.filter.Filters; import com.otaliastudios.cameraview.filter.Filters;
import com.otaliastudios.cameraview.filter.MultiFilter;
import com.otaliastudios.cameraview.filters.BrightnessFilter; import com.otaliastudios.cameraview.filters.BrightnessFilter;
import com.otaliastudios.cameraview.filters.DuotoneFilter;
import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.Frame;
import com.otaliastudios.cameraview.frame.FrameProcessor; import com.otaliastudios.cameraview.frame.FrameProcessor;
@ -334,6 +337,12 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis
Filters filter = mAllFilters[mCurrentFilter]; Filters filter = mAllFilters[mCurrentFilter];
camera.setFilter(filter.newInstance()); camera.setFilter(filter.newInstance());
message(filter.toString(), false); message(filter.toString(), false);
// To test MultiFilter:
// DuotoneFilter duotone = new DuotoneFilter();
// duotone.setFirstColor(Color.RED);
// duotone.setSecondColor(Color.GREEN);
// camera.setFilter(new MultiFilter(duotone, filter.newInstance()));
} }
@Override @Override

@ -91,6 +91,27 @@ camera.mapGesture(Gesture.SCROLL_HORIZONTAL, GestureAction.FILTER_CONTROL_1);
camera.mapGesture(Gesture.SCROLL_VERTICAL, GestureAction.FILTER_CONTROL_2); camera.mapGesture(Gesture.SCROLL_VERTICAL, GestureAction.FILTER_CONTROL_2);
``` ```
### MultiFilter
CameraView provides a special filter called `MultiFilter` which can be used to group different filters
together, and apply them in sequence to the input frame, in order to process it more than once.
```java
camera.setFilter(new MultiFilter(firstFilter, secondFilter, thirdFilter));
```
You can even add new filters to the group at any time, using `MultiFilter.addFilter()`.
The `MultiFilter` will also try to dispatch [filter controls](#filters-controls) (e.g. from gestures)
to its children.
There are some technical caveats when using a `MultiFilter`:
- Using a large number of child filters can consume the available graphic memory
- For [advanced users](#advanced-usage), child filters need to read from `GLES20.GL_TEXTURE_2D`
instead of the typical `GLES11Ext.GL_TEXTURE_EXTERNAL_OES`. To achieve this, we get your fragment
shader String and replace any `"samplerExternalOES"` with `"sampler2D"`. This is a hack
that might cause issues with specific shader implementations.
### Advanced usage ### Advanced usage
Advanced users with OpenGL experience can create their own filters by implementing the `Filter` interface Advanced users with OpenGL experience can create their own filters by implementing the `Filter` interface

Loading…
Cancel
Save