Дисклеймер
Данная статья предназначена для начинающих андроид разработчиков с небольшим опытом работы с видео и/или камерой, особенно тех кто начал разбирать примеры grafika и кому они показались сложными — здесь будет рассмотрен похожий код с упрощенным описанием основных шагов, проиллюстрированных диаграммами состояний.
Почему в заголовке вынесен класс Surface? В android множество классов имеют в своем названии слово Surface (Surface, SurfaceHolder, SurfaceTexture, SurfaceView, GLSurfaceView) они не связаны общей иерархией тем не менее объединены низкоуровневой логикой работы с вывод изображений. Мне показалось разумным использовать его в названии чтобы подчеркнуть попытку раскрытия работы именно с этой частью SDK.
Пример использования с разным API
Попробуем написать следующий пример: будем брать preview с камеры, накладывать на него анимированный drawable, выводить это все на экран и по необходимости записывать в файл. Полный код будет лежать https://github.com/tttzof351/AndroidSurfaceExample/
Для вывода на экраны мы воспользуемся GLSurfaceView, для записи классами MediaCodec и EGLSurface, а с камерой общаться через API V2. Общая схема примерно следующая:
Наложение нескольких Surface
Surface — фактически дескриптор области в памяти, которую нужно заполнить изображением. Скорее всего, мы получаем его пытаясь вывести что-то на экран или в файл, таким образом он работает как буфер для некоторого “процесса” который производит данные.
Чтобы создать наложение из нескольких Surface воспользуемся OpenGL.
Для этого мы создадим две квадратные external-текстуры и получим из них Surface-ы
В коде это будет выглядеть как то так:
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
val textureId = textures[0]
//Как то рассчитаем размеры
val textureWidth = ...
val textureHeight = ...
//Прослойка между
val surfaceTexture = SurfaceTexture(textureId)
surfaceTexture.setDefaultBufferSize(textureWidth, textureHeight)
//Собственно, surface который "связан" с нашей текстурой
val surface = Surface(surfaceTexture)
XYZ координаты
Теперь нам нужно понять как создать и расположить текстуры, а для этого придется вспомнить как устроена координатная сетка в OpenGL: ее центр совпадает с центром сцены (окна), а границы нормированы т.е от -1 до 1.
На этой сцене мы хотим задать два прямоугольника (работа идет на плоскости поэтому все z координаты логично установлены в 0f) — красным мы обозначим тот куда будем помещать preview для камеры, а синим для анимированного drawable-а:
Выпишем наши координаты явно:
fullscreenTexture = floatArrayOf(
// X, Y, Z
-1.0f, -1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 0.0f,
)
smallTexture = floatArrayOf(
// X, Y, Z
0.3f, 0.3f, 0.0f,
0.8f, 0.3f, 0.0f,
0.3f, 0.8f, 0.0f,
0.8f, 0.8f, 0.0f
)
UV координаты
Достаточно ли этого? Оказывается, что нет :(
Текстура это отображение картинки на область сцены и чтобы его правильно совершить нужно указать в какое точно место точки на картинке попадут внутри этой области — для этого в OpenGL применяются UV координаты — они выходят из левого нижнего угла и имеют границы от 0 до 1 по каждой из осей.
Работает это следующим образом — каждой вершине нашей области мы зададим UV координаты и будем искать соответствующие точки на изображении, считая что там ширина и высота равны по 1.
Рассмотрим на примере — будем считать что камера отдает нам изображение в перевернутом и отраженном состоянии и при этом мы хотим показать только правую-верхнюю часть т.е взять 0.8 по широты и высоте изображения.
Тонкий момент — на данном этапе мы не знаем соотношения сторон области на экране — у нас есть только квадрат в относительных координатах, который заполнит собой всю сцену и соответственно растянется. Если бы мы делали fullscreen камеру то наши относительные размеры (2 по каждой стороне) растянулись бы до условных 1080x1920. Будем считать что размеры сцены мы зададим такие что их соотношение будет равно соотношению камеры.
Посмотрим куда перейдут координаты — правая верхняя точка нашей области (1, 1, 0) должна перейти в UV координату (0, 0), левая нижняя в (0.8f, 0.8f) и т. д
Таким образом получим соответствие XYZ и UV:
// X, Y, Z, U, V
-1.0f, -1.0f, 0.0f, 0.8f, 0.8f,
1.0f, -1.0f, 0.0f, 0.8f, 0.0f,
-1.0f, 1.0f, 0.0f, 0.0f, 0.8f,
1.0f, 1.0f, 0.0f, 0.0f, 0.0f
Если соотношение сторон между preview с камеры и областью на экране совпадало изначально то оно очевидным образом продолжит сохранятся т.к в нашем случаи мы просто умножили на 0.8f.
А что будет есть мы зададим значения больше 1? В зависимости от настроек которые мы передали OpenGL-у мы получим точки какой то части изображения. В нашем примере будет повторяться последняя линия по соответствующей оси и мы увидим артефакты в виде “полосок”
Итог: если мы хотим сжать/вырезать изображение сохраняя при этом позицию области на экране то UV координаты наш выбор!
Зададим координаты для наших текстур
fullscreenTexture = floatArrayOf(
// X, Y, Z, U, V
-1.0f, -1.0f, 0.0f, 1f, 0f,
1.0f, -1.0f, 0.0f, 0f, 0f,
-1.0f, 1.0f, 0.0f, 1f, 1f,
1.0f, 1.0f, 0.0f, 0f, 1f
)
smallTexture = floatArrayOf(
// X, Y, Z, U, V
0.3f, 0.3f, 0.0f, 0f, 0f,
0.8f, 0.3f, 0.0f, 1f, 0f,
0.3f, 0.8f, 0.0f, 0f, 1f,
0.8f, 0.8f, 0.0f, 1f, 1f
)
Шейдеры
Иметь статичные XYZ и UV-координаты не очень удобно — мы например можем захотеть перемещать и масштабировать жестами наши текстуры. Чтобы их трансформировать заведем две матрицы для каждой текстуры: MVPMatrix и TexMatrix для для XYZ и UV координат соответственно.
Каждая OpenGL2 должна содержать шейдеры для того, чтобы вывести что-то на экран. Конечно, это не там тема которую можно раскрыть в одном абзаце, тем не менее в нашем случае они будут тривиальными, а потому можно быстро понять что они что они делают, без особого знания материала.
Прежде всего шейдера два — vertex и fragment.
Первый (vertex) будет обрабатывает наши вершины, а именно просто перемножать наши XYZ / UV координаты с соответствующими им матрицами и заполнять OpenGL переменную gl_Position которая как раз отвечает за финальное положение нашей текстуры на экране.
Второй (fragment) должен заполнить gl_FragColor пикселями изображения.
Итого имеем: переменные внутри vertex шейдера мы должны заполнить поля нашими данными, а именно:
- MVPMatrix -> uMVPMatrix
- TexMatrix -> uTexMatrix
- наши XYZ координаты вершины -> aPosition
- UV координаты -> aTextureCoord
vTextureCoord — нужна для проброса данных из vertex шейдера в fragment шейдер
В fragment шейдере мы берем преобразованные UV координаты и используем их для отображения пикселей изображения в области текстуры.
val vertexShader = """
uniform mat4 uMVPMatrix;
uniform mat4 uTexMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uTexMatrix * aTextureCoord).xy;
}
"""
val fragmentShader = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
"""
Ради справки укажем чем отличаются типы:
- uniform — переменная такого типа будет сохранять значения при многократном вызове, мы используем один шейдер которые вызывается последовательно для двух текстур, так что все равно будем перезаписывать при каждой отрисовки
- attribute — данные такого типа читаются из вершинного буфера, их нужно загружать при каждой отрисовки
- varying — нужны для передачи данных из vertex шейдера в fragment
Как передать параметры в шейдер? Для этого вначале нужно получить id (указатель) переменной:
val aPositionHandle = GLES20.glGetAttribLocation(programId, "aPosition")
Теперь по этому id нужно загрузить данные:
//Вначале превратим наш массив вершин во floatbuffer
val verticesBuffer = ByteBuffer.allocateDirect(
fullscreenTexture.size * FLOAT_SIZE_BYTES
).order(
ByteOrder.nativeOrder()
).asFloatBuffer()
verticesBuffer.put(fullscreenTexture).position(0)
/*
Установим начальное смещение - для XYZ это будет 0 т.к они находится в начале
Затема передадим id нашего аттрибута куда мы загружаем данные, указав сколько
значений координат брать, и какое то смещение в байтах нужно затем совершить чтобы попасть в следующую вершуны - 5 это кол-во XYZUV, а 4 - кол-во байт во float
*/
verticesBuffer.position(0)
GLES20.glVertexAttribPointer(
aPositionHandle,
3,
GLES20.GL_FLOAT,
false,
5 * 4,
verticesBuffer
)
Непосредственно отрисовка
После того как мы заполнили наши шейдеры всеми данными мы должны попросить текстуру обновить изображение, а OpenGL отрисовать наши вершины:
fun updateFrame(...) {
...
surfaceTexture.updateTexImage()
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
}
В нашем примере мы разобьем работу с OpenGL сценой на два классы — непосредственно сцены и текстуры:
class OpenGLExternalTexture(verticesData: FloatArray, ...) {
val surfaceTexture: SurfaceTexture
val surface: Surface
init {
//Проинициализируем матрицы, создадим текстуру и т.д
}
...
fun updateFrame(aPositionHandle: Int, ...) {...} //заполним данные, отрисуем кадр
fun release() {...} // отчистим данные
}
class OpenGLScene(
sceneWidth: Int,
sceneHeight: Int,
...
) {
val fullscreenTexture = OpenGLExternalTexture(...)
val smallTexture = OpenGLExternalTexture(..)
val aPositionHandle: Int
...
init {
//Создадим шейдеры, получим все указатали на переменные шейдера и т.д
}
fun updateFrame() {
...
fullscreenTexture.updateFrame(aPositionHandle, ...)
smallTexture.updateFrame(aPositionHandle, ...)
}
fun release() {
fullscreenTexture.release()
smallTexture.release()
}
}
StateMachine / Машина состояний / Конечный автомат
Все API которое мы предполагаем использовать в нашем примере принципиально асинхронное (ну может за исключением анимированного Drawable-а). Мы будем заворачивать такие вызовы в отдельные StateMachine-ы — подходе когда явно выписывают состояния системы, а переходы между ними происходят через отправку событий.
Давайте на простом примере посмотрим как это будет выглядеть, предположим у нас есть такое код:
imageView.setOnClickListener {
loadImage { bitmap ->
imageView.setBitmap(bitmap)
}
}
В целом все хорошо — красиво и компактно, но мы попробуем переписать его в следующим образом:
val uiMachine = UIMachine()
imageView.setOnClickListener { uiMachine.send(Click(imageView)) }
class UIMachine {
var state: State = WaitClick()
fun send(action: Action) = transition(action)
private fun transition(action: Action) {
val state = this.state
when {
state is WaitingClick && action is Click -> {
this.state = WaitBitmap(imageView = action.imageView)
loadImage { send(BitmapIsReady(bitmap = it)) }
}
state is WaitingBitmap && action is BitmapIsReady -> {
this.state = WaitClick
state.imageView.setImageBitmap(action.bitmap)
}
}
}
}
sealed class State {
object WaitingClick : State()
class WaitingBitmap(val imageView: ImageView): State()
}
sealed class Action {
class Click(val imageView: ImageView): Action()
class BitmapIsReady(val bitmap: Bitmap): Action()
}
С одной стороны получилось сильно больше, тем не менее появилось несколько неявных, но полезных свойств: многократное нажатие теперь не приводит к лишним запускам loadImage, хотя и не очевидно с таким объемом, но мы избавились от вложенного вызова колбеков, чем и будем в последствии пользоваться, а еще стиль написания метода transition позволяет построить диаграмму переходов которая один в один повторяет код т.е в нашем случаи:
Серым указаны переходы, которые не выписаны явно. Часто их логируют или кидают исключение, считая признаком ошибки. Мы пока обойдемся простым игнорированием и в дальнейшем не будем указывать на схемах.
Создадим базовый интерфейсы для StateMachine:
interface Action
interface State
interface StateMachine<S : State, A : Action> {
var state: S
fun transition(action: A)
fun send(action: A)
}
GLSurfaceView
Самый простой способ вывести что-то на экран используя OpenGL в android это класс GLSurfaceView — он автоматически создает новые поток для рисования, запуск/пауза которого происходит по методам GLSurfaceView::onResume/onPause.
Для простоты мы будем задавать нашей вьюхе соотношение 16:9
Сам процесс отрисовки вынесен в отдельный колбек — GLSurfaceView.Renderer.
Завернув его в StateMachine-у мы получим что-то вроде этого:
class GLSurfaceMachine: StateMachine<GLSurfaceState, GLSurfaceAction> {
override var state: GLSurfaceState = WaitCreate()
override fun send(action: GLSurfaceAction) = transition(action)
override fun transition(action: GLSurfaceAction) {
val state = this.state
when {
state is WaitCreate && action is Create -> {
this.state = WaitSurfaceReady(...)
this.state.glSurfaceView?.setRenderer(object :Renderer {
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int)
send(SurfaceReady(width, height, gl))
}
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
}
override fun onDrawFrame(gl: GL10?) {
send(Draw)
}
})
}
state is WaitSurfaceReady && action is SurfaceReady -> {
val openGLScene = OpenGLScene(width, height)
this.state = DrawingAvailable(openGLScene, ...)
}
state is DrawingAvailable && action is Draw -> {
state.openGLScene.updateFrame()
}
state !is WaitCreate && action is Stop -> {
state.uiHolder.glSurfaceView?.onPause()
state.uiHolder.openGLScene?.release()
this.state = WaitSurfaceReady()
}
state is WaitSurfaceReady && action is Start -> {
state.uiHolder.glSurfaceView?.onResume()
}
}
}
}
...
val glSurfaceMachine = GLSurfaceMachine()
val glSurfaceView = findViewById(R.id.gl_view)
glSurfaceView.layoutParams.width = width
glSurfaceView.layoutParams.height = ((16f/9f) * width).toInt()
glSurfaceMachine.send(GLSurfaceAction.Create(glSurfaceView, ...))
Давайте нарисуем диаграмму переходов:
Теперь наш код пытается что-то выводить на экран, правда пока у него это получается плохо — ни чего кроме черного экрана мы не увидим. Как не сложно догадаться дело в том, что в наши Surface-ы сейчас ни чего не попадает т.к мы пока не реализовали источники изображений. Давайте это исправим — первым делом создадим CanvasDrawable:
class CanvasDrawable : Drawable() {
private val backgroundPaint = Paint().apply { ... }
private val circlePaint = Paint().apply { ... }
override fun draw(canvas: Canvas) {
canvas.drawRect(bounds, backgroundPaint)
val width = bounds.width()
val height = bounds.height()
val posX = ...
val posY = ...
canvas.drawCircle(posX, posY, 0.1f * width, circlePaint)
}
...
}
Теперь секцию в GLSurfaceMachine мы можем дополнить отрисовкой canvasDrawable на canvas-е которые предоставляет surface у соответствующей текстуры:
state is DrawingAvailable && action is Draw -> {
val canvasDrawable = state.canvasDrawable
val smallTexture = state.openGLScene.smallTexture
val bounds = canvasDrawable.bounds
val canvas = smallSurface.lockCanvas(bounds)
canvasDrawable.draw(canvas)
smallSurface.unlockCanvasAndPost(canvas)
state.openGLScene.updateFrame()
}
После чего увидим что-то наподобие:
Camera API V2
Зеленый прямоугольник это конечно весело и интригующе, но пора попробовать вывести preview с камеры на оставшейся surface.
Давайте выпишем этапы работы с камерой:
- Ожидаем получение permission-а. У нас это будет состояние WaitingStart
- Получаем инстанс camera manager-а, находим логический id (обычно их два — для back и front, а логический он потому что на современных девайсах камера может состоять из множества датчиков) нужной камеры, выбираем подходящий размер, открываем камеру, получаем cameraDevice. Состояние WaitingOpen
val manager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
var resultCameraId: String? = null
var resultSize: Size? = null
for (cameraId in manager.cameraIdList) {
val chars = manager.getCameraCharacteristics(cameraId)
val facing = chars.get(CameraCharacteristics.LENS_FACING) ?: -1
if (facing == LENS_FACING_BACK) {
val confMap = chars.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
)
val sizes = confMap?.getOutputSizes(SurfaceTexture::class.java)
resultSize = findSize(sizes)
resultCameraId = cameraId
break
}
}
resultCameraId?.let { cameraId ->
manager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
//Success open camera
...
}
})
}
- Имея открытую камеру мы обратимся в обратимся за получением Surface-а для вывода изображения. Состояние WaitingSurface
- Теперь имея cameraDevice, Surface мы должны открыть сессию чтобы камера наконец начала передавать данные. Состояние WaitingSession
cameraDevice.createCaptureSession(
arrayListOf(surface),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
send(CameraAction.SessionReady(session))
}
},
handler
)
- Теперь мы можем захватить preview. Состояние StartingPreview
val request = cameraDevice.createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW
).apply {
addTarget(surface)
}
session.setRepeatingRequest(
request.build(),
object : CameraCaptureSession.CaptureCallback() {...}
handler
)
Проиллюстрируем нашу текущую схему:
MediaCodec
MediaCodec класс для низкоуровневой работы с системными кодеками, в общем виде его API это набор input/output буферов (звучит, к сожалению, проще чем работать с ним) в которые помещаются данные (сырые или закодированные зависит от режима работы encoder/decoder), а на выходе мы получаем результат.
Несмотря на то, что к качестве буферов обычно выступают ByteBuffer, для работы с видео можно использовать Surface который вернет нам MediaCodec::createInputSurface, на нем мы должны отрисовывать кадры, которое хотим записать (при таком подходе документация обещает нам ускорение кодирования за счет использования gpu).
Хорошо, теперь мы должны научиться отрисовывать уже существующие Surface-ы которое мы создали в GLSurfaceMachine на Surface от MediaCodec-а. При этом важно помнить: Surface это объект который создает consumer-ом и прочитать что-то из него в общем случаи нельзя т.е нет условного метода getBitmap/readImage/…
Мы поступим следующим образом: на основе существующего GL контекста мы создадим новый который будем иметь общую с ним память, а потому мы сможем использовать переиспользовать там id-шники текстур которые мы создали ранее. Затем используя новый GL контекст и Surface от MediaCodec-а, мы создадим EGLSurface — внеэкранный буфер на котором мы так же сможем создать наш класс OpenGLScene. Затем при каждой отрисовке кадра мы попробуем параллельно записывать кадр на файл.
EGL означает интерфейс взаимодействия OpenGL API с оконной подсистемой платформы, работу с ним мы украдем из grafika. Конвейер (EncoderHelper) с MediaCodec-ом напрямую описывать тоже не буду, приведу лишь итоговую схему взаимодействия наших компонентов:
EncoderMachine.kt
EncoderHelper.kt
Итог:
- Работа с видео требует хотя бы базовых навыков в OpenGL-е
- Media API android достаточно низкоуровневое, что дает гибкость, однако заставляет иногда писать чуть больше код чем хотелось бы
- Асинхронное API можно заворачивать в StateMachine-ы