Skip to content

Commit

Permalink
Add a thumbnail strip effect that tiles frames horizontally.
Browse files Browse the repository at this point in the history
The size of the thumbnail strip and the timestamp of the video frames to use must be specified by the user of the effect.

PiperOrigin-RevId: 552809210
  • Loading branch information
Googler authored and tianyif committed Aug 7, 2023
1 parent 267d416 commit e87f0d5
Show file tree
Hide file tree
Showing 7 changed files with 422 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.effect;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.test.utils.BitmapPixelTestUtil.MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE;
import static androidx.media3.test.utils.BitmapPixelTestUtil.createArgb8888BitmapFromFocusedGlFramebuffer;
import static androidx.media3.test.utils.BitmapPixelTestUtil.createArgb8888BitmapWithSolidColor;
import static androidx.media3.test.utils.BitmapPixelTestUtil.createGlTextureFromBitmap;
import static androidx.media3.test.utils.BitmapPixelTestUtil.getBitmapAveragePixelAbsoluteDifferenceArgb8888;
import static androidx.media3.test.utils.BitmapPixelTestUtil.maybeSaveTestBitmap;
import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap;
import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static com.google.common.truth.Truth.assertThat;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.opengl.EGLContext;
import android.opengl.EGLDisplay;
import android.opengl.EGLSurface;
import androidx.media3.common.VideoFrameProcessingException;
import androidx.media3.common.util.GlUtil;
import androidx.media3.common.util.Util;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Pixel tests for {@link ThumbnailStripEffect}. */
@RunWith(AndroidJUnit4.class)
public final class ThumbnailStripEffectPixelTest {
private static final String ORIGINAL_PNG_ASSET_PATH =
"media/bitmap/sample_mp4_first_frame/linear_colors/original.png";
private static final String TWO_THUMBNAILS_STRIP_PNG_ASSET_PATH =
"media/bitmap/sample_mp4_first_frame/linear_colors/two_thumbnails_strip.png";

private final Context context = getApplicationContext();

private @MonotonicNonNull EGLDisplay eglDisplay;
private @MonotonicNonNull EGLContext eglContext;
private @MonotonicNonNull EGLSurface placeholderEglSurface;
private @MonotonicNonNull ThumbnailStripShaderProgram thumbnailStripShaderProgram;
private int inputTexId;
private int inputWidth;
private int inputHeight;

@Before
public void setUp() throws Exception {
eglDisplay = GlUtil.getDefaultEglDisplay();
eglContext = GlUtil.createEglContext(eglDisplay);
placeholderEglSurface = GlUtil.createFocusedPlaceholderEglSurface(eglContext, eglDisplay);

Bitmap inputBitmap = readBitmap(ORIGINAL_PNG_ASSET_PATH);
inputWidth = inputBitmap.getWidth();
inputHeight = inputBitmap.getHeight();
inputTexId = createGlTextureFromBitmap(inputBitmap);

int outputTexId =
GlUtil.createTexture(inputWidth, inputHeight, /* useHighPrecisionColorComponents= */ false);
int frameBuffer = GlUtil.createFboForTexture(outputTexId);
GlUtil.focusFramebuffer(
checkNotNull(eglDisplay),
checkNotNull(eglContext),
checkNotNull(placeholderEglSurface),
frameBuffer,
inputWidth,
inputHeight);
}

@After
public void tearDown() throws GlUtil.GlException, VideoFrameProcessingException {
if (thumbnailStripShaderProgram != null) {
thumbnailStripShaderProgram.release();
}
GlUtil.destroyEglContext(eglDisplay, eglContext);
}

@Test
public void drawFrame_withOneTimestampAndOriginalSize_producesOriginalFrame() throws Exception {
String testId = "drawFrame_withOneTimestampAndOriginalSize";
ThumbnailStripEffect thumbnailStripEffect = new ThumbnailStripEffect(inputWidth, inputHeight);
thumbnailStripEffect.setTimestampsMs(ImmutableList.of(0L));
thumbnailStripShaderProgram =
thumbnailStripEffect.toGlShaderProgram(context, /* useHdr= */ false);
Bitmap expectedBitmap = readBitmap(ORIGINAL_PNG_ASSET_PATH);

thumbnailStripShaderProgram.drawFrame(inputTexId, /* presentationTimeUs= */ 0L);
Bitmap actualBitmap = createArgb8888BitmapFromFocusedGlFramebuffer(inputWidth, inputHeight);

maybeSaveTestBitmap(testId, /* bitmapLabel= */ "actual", actualBitmap, /* path= */ null);
float averagePixelAbsoluteDifference =
getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId);
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
}

@Test
public void drawFrame_zeroTimestamps_producesEmptyFrame() throws Exception {
String testId = "drawFrame_zeroTimestamps";
ThumbnailStripEffect thumbnailStripEffect = new ThumbnailStripEffect(inputWidth, inputHeight);
thumbnailStripEffect.setTimestampsMs(ImmutableList.of());
thumbnailStripShaderProgram =
thumbnailStripEffect.toGlShaderProgram(context, /* useHdr= */ false);
Bitmap expectedBitmap =
createArgb8888BitmapWithSolidColor(inputWidth, inputHeight, Color.TRANSPARENT);

thumbnailStripShaderProgram.drawFrame(inputTexId, /* presentationTimeUs= */ 0L);
Bitmap actualBitmap = createArgb8888BitmapFromFocusedGlFramebuffer(inputWidth, inputHeight);

maybeSaveTestBitmap(testId, /* bitmapLabel= */ "actual", actualBitmap, /* path= */ null);
float averagePixelAbsoluteDifference =
getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId);
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
}

@Test
public void drawFrame_lateTimestamp_producesEmptyFrame() throws Exception {
String testId = "drawFrame_lateTimestamp";
ThumbnailStripEffect thumbnailStripEffect = new ThumbnailStripEffect(inputWidth, inputHeight);
thumbnailStripEffect.setTimestampsMs(ImmutableList.of(1L));
thumbnailStripShaderProgram =
thumbnailStripEffect.toGlShaderProgram(context, /* useHdr= */ false);
Bitmap expectedBitmap =
createArgb8888BitmapWithSolidColor(inputWidth, inputHeight, Color.TRANSPARENT);

thumbnailStripShaderProgram.drawFrame(inputTexId, /* presentationTimeUs= */ 0L);
Bitmap actualBitmap = createArgb8888BitmapFromFocusedGlFramebuffer(inputWidth, inputHeight);

maybeSaveTestBitmap(testId, /* bitmapLabel= */ "actual", actualBitmap, /* path= */ null);
float averagePixelAbsoluteDifference =
getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId);
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
}

@Test
public void drawFrame_twoTimestamps_producesStrip() throws Exception {
String testId = "drawFrame_twoTimestamps";
ThumbnailStripEffect thumbnailStripEffect = new ThumbnailStripEffect(inputWidth, inputHeight);
thumbnailStripEffect.setTimestampsMs(ImmutableList.of(0L, 1L));
thumbnailStripShaderProgram =
thumbnailStripEffect.toGlShaderProgram(context, /* useHdr= */ false);
Bitmap expectedBitmap = readBitmap(TWO_THUMBNAILS_STRIP_PNG_ASSET_PATH);

thumbnailStripShaderProgram.drawFrame(inputTexId, /* presentationTimeUs= */ 0L);
thumbnailStripShaderProgram.drawFrame(inputTexId, /* presentationTimeUs= */ Util.msToUs(1L));
Bitmap actualBitmap = createArgb8888BitmapFromFocusedGlFramebuffer(inputWidth, inputHeight);

maybeSaveTestBitmap(testId, /* bitmapLabel= */ "actual", actualBitmap, /* path= */ null);
float averagePixelAbsoluteDifference =
getBitmapAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, actualBitmap, testId);
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#version 100
// Copyright 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// ES 2 fragment shader that samples from a (non-external) texture with
// uTexSampler.

precision mediump float;
uniform sampler2D uTexSampler;
varying vec2 vTexSamplingCoord;

void main() {
vec3 src = texture2D(uTexSampler, vTexSamplingCoord).xyz;
gl_FragColor = vec4(src, 1.0);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#version 100
// Copyright 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// ES2 vertex shader that tiles frames horizontally.

attribute vec4 aFramePosition;
uniform int uIndex;
uniform int uCount;
varying vec2 vTexSamplingCoord;

void main() {
// Translate the coordinates from -1,+1 to 0,+2.
float x = aFramePosition.x + 1.0;
// Offset the frame by its index times its width (2).
x += float(uIndex) * 2.0;
// Shrink the frame to fit the thumbnail strip.
x /= float(uCount);
// Translate the coordinates back to -1,+1.
x -= 1.0;

gl_Position = vec4(x, aFramePosition.yzw);
vTexSamplingCoord = aFramePosition.xy * 0.5 + 0.5;
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ public void setErrorListener(Executor errorListenerExecutor, ErrorListener error
this.errorListener = errorListener;
}

/**
* Returns {@code true} if the texture buffer should be cleared before calling {@link #drawFrame}
* or {@code false} if it should retain the content of the last drawn frame.
*/
public boolean shouldClearTextureBuffer() {
return true;
}

@Override
public void queueInputFrame(
GlObjectsProvider glObjectsProvider, GlTextureInfo inputTexture, long presentationTimeUs) {
Expand All @@ -128,7 +136,9 @@ public void queueInputFrame(
// Copy frame to fbo.
GlUtil.focusFramebufferUsingCurrentContext(
outputTexture.fboId, outputTexture.width, outputTexture.height);
GlUtil.clearFocusedBuffers();
if (shouldClearTextureBuffer()) {
GlUtil.clearFocusedBuffers();
}
drawFrame(inputTexture.texId, presentationTimeUs);
inputListener.onInputFrameProcessed(inputTexture);
outputListener.onOutputFrameAvailable(outputTexture, presentationTimeUs);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.effect;

import android.content.Context;
import androidx.media3.common.C;
import androidx.media3.common.VideoFrameProcessingException;
import androidx.media3.common.util.UnstableApi;
import java.util.ArrayList;
import java.util.List;

/**
* Generate a thumbnail strip (i.e. tile frames horizontally) containing frames at given {@link
* #setTimestampsMs timestamps}.
*/
@UnstableApi
/* package */ final class ThumbnailStripEffect implements GlEffect {

/* package */ final int stripWidth;
/* package */ final int stripHeight;
private final List<Long> timestampsMs;
private int currentThumbnailIndex;

/**
* Creates a new instance with the given size. No thumbnails are drawn by default, call {@link
* #setTimestampsMs} to change how many to draw and their timestamp.
*
* @param stripWidth The width of the thumbnail strip.
* @param stripHeight The height of the thumbnail strip.
*/
public ThumbnailStripEffect(int stripWidth, int stripHeight) {
this.stripWidth = stripWidth;
this.stripHeight = stripHeight;
timestampsMs = new ArrayList<>();
}

@Override
public ThumbnailStripShaderProgram toGlShaderProgram(Context context, boolean useHdr)
throws VideoFrameProcessingException {
return new ThumbnailStripShaderProgram(context, useHdr, this);
}

/**
* Sets the timestamps of the frames to draw, in milliseconds.
*
* <p>The timestamp represents the minimum presentation time of the next frame added to the strip.
* For example, if the timestamp is 10, a frame with a time of 100 will be drawn but one with a
* time of 9 will be ignored.
*/
public void setTimestampsMs(List<Long> timestampsMs) {
this.timestampsMs.clear();
this.timestampsMs.addAll(timestampsMs);
currentThumbnailIndex = 0;
}

/** Returns whether all the thumbnails have already been drawn. */
public boolean isDone() {
return currentThumbnailIndex >= timestampsMs.size();
}

/** Returns the index of the next thumbnail to draw. */
public int getNextThumbnailIndex() {
return currentThumbnailIndex;
}

/** Returns the timestamp in milliseconds of the next thumbnail to draw. */
public long getNextTimestampMs() {
return isDone() ? C.TIME_END_OF_SOURCE : timestampsMs.get(currentThumbnailIndex);
}

/** Returns the total number of thumbnails to be drawn in the strip. */
public int getNumberOfThumbnails() {
return timestampsMs.size();
}

/* package */ void onThumbnailDrawn() {
currentThumbnailIndex++;
}
}
Loading

3 comments on commit e87f0d5

@FongMi
Copy link

@FongMi FongMi commented on e87f0d5 Aug 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#365
Is it possible to implement this function ?

@andrewlewis
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FongMi This commit is prototyping part of the functionality needed for generating a thumbnail strip, but there's still a lot of work to do to make it an easy to use and polished API. It will probably be a while until we have something that works out of the box.

@FongMi
Copy link

@FongMi FongMi commented on e87f0d5 Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@FongMi This commit is prototyping part of the functionality needed for generating a thumbnail strip, but there's still a lot of work to do to make it an easy to use and polished API. It will probably be a while until we have something that works out of the box.

Thanks to reply, I see.

Please sign in to comment.