Дисклеймер
Данная статья предназначена для начинающих андроид разработчиков с небольшим опытом работы с видео и/или камерой, особенно тех кто начал разбирать примеры 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 по каждой стороне) растянулись бы до условных 1080×1920. Будем считать что размеры сцены мы зададим такие что их соотношение будет равно соотношению камеры.
Посмотрим куда перейдут координаты — правая верхняя точка нашей области (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-ы
ссылка на оригинал статьи https://habr.com/ru/post/480878/
Добавить комментарий