feat(media): голосовые сообщения и видеокружки
- голосовые: запись/отправка/воспроизведение, Ogg/Opus (libopus FFI на Windows/Android), волна, анимации, lock + slide-to-cancel - видеокружки: нативная квадратная запись 480x480 (Camera2 + GL), аудио mono 48k, снятие video edit-list для прохождения серверного валидатора - file_uploader: единый uploadMediaFile (аудио+видео), чтение полного ответа CDN - проигрывание видео и кружков в message_bubble
This commit is contained in:
@@ -81,4 +81,7 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation("androidx.media3:media3-transformer:1.9.3")
|
||||
implementation("androidx.media3:media3-effect:1.9.3")
|
||||
implementation("androidx.media3:media3-common:1.9.3")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,22 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import android.media.MediaCodecInfo
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.Effect
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.audio.ChannelMixingAudioProcessor
|
||||
import androidx.media3.common.audio.ChannelMixingMatrix
|
||||
import androidx.media3.effect.Presentation
|
||||
import androidx.media3.transformer.Composition
|
||||
import androidx.media3.transformer.DefaultEncoderFactory
|
||||
import androidx.media3.transformer.EditedMediaItem
|
||||
import androidx.media3.transformer.Effects
|
||||
import androidx.media3.transformer.ExportException
|
||||
import androidx.media3.transformer.ExportResult
|
||||
import androidx.media3.transformer.Transformer
|
||||
import androidx.media3.transformer.VideoEncoderSettings
|
||||
import java.io.File
|
||||
import java.net.NetworkInterface
|
||||
import java.util.Collections
|
||||
import java.util.Random
|
||||
@@ -40,6 +56,7 @@ class MainActivity : FlutterActivity() {
|
||||
@Volatile private var nfcCycling = false
|
||||
private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) }
|
||||
|
||||
private var noteRecorder: VideoNoteRecorder? = null
|
||||
private var ble: BleContactExchange? = null
|
||||
private var pendingSelfId = 0L
|
||||
private var pendingSelfPhone = 0L
|
||||
@@ -195,6 +212,115 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/video_note",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"init" -> {
|
||||
val front = call.argument<Boolean>("front") ?: true
|
||||
val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer)
|
||||
noteRecorder?.dispose()
|
||||
noteRecorder = rec
|
||||
rec.init(front, result)
|
||||
}
|
||||
"start" -> noteRecorder?.start(result)
|
||||
?: result.error("NOT_READY", "recorder not initialized", null)
|
||||
"stop" -> noteRecorder?.stop(result)
|
||||
?: result.error("NOT_READY", "recorder not initialized", null)
|
||||
"dispose" -> {
|
||||
noteRecorder?.dispose()
|
||||
noteRecorder = null
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/video",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"cropSquare" -> {
|
||||
val input = call.argument<String>("input")
|
||||
val output = call.argument<String>("output")
|
||||
val size = call.argument<Int>("size") ?: 480
|
||||
if (input == null || output == null) {
|
||||
result.error("BAD_ARGS", "input/output required", null)
|
||||
} else {
|
||||
cropSquare(input, output, size, result)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Центр-кроп видео в квадрат size×size (без искажений) через media3
|
||||
// Transformer: LAYOUT_SCALE_TO_FIT_WITH_CROP заполняет квадрат и обрезает
|
||||
// лишнее по бокам.
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun cropSquare(
|
||||
input: String,
|
||||
output: String,
|
||||
size: Int,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
try {
|
||||
// Параметры энкодера как у официального клиента: H.264 ~1 Мбит/с CBR.
|
||||
val videoSettings = VideoEncoderSettings.Builder()
|
||||
.setBitrate(1_024_000)
|
||||
.setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
|
||||
.build()
|
||||
val encoderFactory = DefaultEncoderFactory.Builder(this)
|
||||
.setRequestedVideoEncoderSettings(videoSettings)
|
||||
.build()
|
||||
val transformer = Transformer.Builder(this)
|
||||
.setEncoderFactory(encoderFactory)
|
||||
.addListener(object : Transformer.Listener {
|
||||
override fun onCompleted(
|
||||
composition: Composition,
|
||||
exportResult: ExportResult,
|
||||
) {
|
||||
result.success(output)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
composition: Composition,
|
||||
exportResult: ExportResult,
|
||||
exportException: ExportException,
|
||||
) {
|
||||
result.error(
|
||||
"TRANSCODE_FAILED",
|
||||
exportException.message,
|
||||
null,
|
||||
)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
// Аудио в моно (как у клиента).
|
||||
val mono = ChannelMixingAudioProcessor()
|
||||
mono.putChannelMixingMatrix(ChannelMixingMatrix.create(1, 1))
|
||||
mono.putChannelMixingMatrix(ChannelMixingMatrix.create(2, 1))
|
||||
val effects = Effects(
|
||||
listOf(mono),
|
||||
listOf<Effect>(
|
||||
Presentation.createForWidthAndHeight(
|
||||
size,
|
||||
size,
|
||||
Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
|
||||
),
|
||||
),
|
||||
)
|
||||
val edited = EditedMediaItem.Builder(
|
||||
MediaItem.fromUri(Uri.fromFile(File(input))),
|
||||
).setEffects(effects).build()
|
||||
transformer.start(edited, output)
|
||||
} catch (e: Exception) {
|
||||
result.error("TRANSCODE_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nfcStatus(): Map<String, Any> {
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.params.StreamConfigurationMap
|
||||
import android.media.MediaRecorder
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.Matrix
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.TextureRegistry
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный
|
||||
// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит
|
||||
// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264,
|
||||
// framework MediaMuxer). Так делает официальный клиент через CameraX — выход
|
||||
// проходит серверный валидатор (media3-перекод его НЕ проходит).
|
||||
class VideoNoteRecorder(
|
||||
private val context: Context,
|
||||
private val textureRegistry: TextureRegistry,
|
||||
) {
|
||||
private val tag = "VideoNoteRecorder"
|
||||
private val edge = 480
|
||||
private val bitrate = 1_024_000
|
||||
private val fps = 30
|
||||
|
||||
private var cameraId = ""
|
||||
private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT
|
||||
private var sensorOrientation = 270
|
||||
private var camSize = Size(1280, 720)
|
||||
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var session: CameraCaptureSession? = null
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputPath: String? = null
|
||||
|
||||
private var camThread: HandlerThread? = null
|
||||
private var camHandler: Handler? = null
|
||||
private var glThread: HandlerThread? = null
|
||||
private var glHandler: Handler? = null
|
||||
|
||||
private var flutterEntry: TextureRegistry.SurfaceTextureEntry? = null
|
||||
private var previewSurface: Surface? = null
|
||||
private var recorderSurface: Surface? = null
|
||||
|
||||
// GL state (живёт на glThread)
|
||||
private var egl: EglCore? = null
|
||||
private var previewWindow: WindowSurface? = null
|
||||
private var recordWindow: WindowSurface? = null
|
||||
private var oesTexId = 0
|
||||
private var camTexture: SurfaceTexture? = null
|
||||
private var camInputSurface: Surface? = null
|
||||
private var program: OesProgram? = null
|
||||
private val stMatrix = FloatArray(16)
|
||||
|
||||
@Volatile private var recording = false
|
||||
@Volatile private var glReady = false
|
||||
|
||||
private fun manager() =
|
||||
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
|
||||
private fun selectCamera(facing: Int): Boolean {
|
||||
val mgr = manager()
|
||||
for (id in mgr.cameraIdList) {
|
||||
val ch = mgr.getCameraCharacteristics(id)
|
||||
if (ch.get(CameraCharacteristics.LENS_FACING) == facing) {
|
||||
cameraId = id
|
||||
lensFacing = facing
|
||||
sensorOrientation =
|
||||
ch.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 270
|
||||
val map = ch.get(
|
||||
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP,
|
||||
)
|
||||
camSize = pickCamSize(map)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p.
|
||||
private fun pickCamSize(map: StreamConfigurationMap?): Size {
|
||||
val sizes = map?.getOutputSizes(SurfaceTexture::class.java)
|
||||
?: return Size(1280, 720)
|
||||
var best = sizes.firstOrNull() ?: Size(1280, 720)
|
||||
var bestScore = Int.MAX_VALUE
|
||||
for (s in sizes) {
|
||||
val longSide = maxOf(s.width, s.height)
|
||||
val shortSide = minOf(s.width, s.height)
|
||||
if (shortSide < edge) continue
|
||||
val score = kotlin.math.abs(longSide - 1280) + kotlin.math.abs(shortSide - 720)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = s
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
fun init(facingFront: Boolean, result: MethodChannel.Result) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
result.error("NO_PERMISSION", "camera permission required", null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val facing = if (facingFront) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT
|
||||
} else {
|
||||
CameraCharacteristics.LENS_FACING_BACK
|
||||
}
|
||||
if (!selectCamera(facing) &&
|
||||
!selectCamera(CameraCharacteristics.LENS_FACING_BACK)
|
||||
) {
|
||||
result.error("NO_CAMERA", "no camera found", null)
|
||||
return
|
||||
}
|
||||
camThread = HandlerThread("VideoNoteCam").also { it.start() }
|
||||
camHandler = Handler(camThread!!.looper)
|
||||
glThread = HandlerThread("VideoNoteGL").also { it.start() }
|
||||
glHandler = Handler(glThread!!.looper)
|
||||
|
||||
val entry = textureRegistry.createSurfaceTexture()
|
||||
flutterEntry = entry
|
||||
entry.surfaceTexture().setDefaultBufferSize(edge, edge)
|
||||
previewSurface = Surface(entry.surfaceTexture())
|
||||
|
||||
glHandler!!.post {
|
||||
try {
|
||||
setupGl()
|
||||
glReady = true
|
||||
openCamera(result, entry.id())
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "GL setup failed", e)
|
||||
result.error("GL_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "init failed", e)
|
||||
result.error("INIT_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
// === GL (на glThread) ===
|
||||
private fun setupGl() {
|
||||
val core = EglCore()
|
||||
egl = core
|
||||
previewWindow = WindowSurface(core, previewSurface!!, core.displayConfig)
|
||||
.also { it.makeCurrent() }
|
||||
program = OesProgram()
|
||||
oesTexId = program!!.createOesTexture()
|
||||
val st = SurfaceTexture(oesTexId)
|
||||
st.setDefaultBufferSize(camSize.width, camSize.height)
|
||||
st.setOnFrameAvailableListener({ onFrame() }, glHandler)
|
||||
camTexture = st
|
||||
camInputSurface = Surface(st)
|
||||
Log.i(tag, "GL setup ok cam=$camSize")
|
||||
}
|
||||
|
||||
private var frameCount = 0
|
||||
|
||||
private fun onFrame() {
|
||||
val st = camTexture ?: return
|
||||
val prog = program ?: return
|
||||
val w = previewWindow ?: return
|
||||
try {
|
||||
w.makeCurrent()
|
||||
st.updateTexImage()
|
||||
st.getTransformMatrix(stMatrix)
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "onFrame update: ${e.message}")
|
||||
return
|
||||
}
|
||||
run {
|
||||
GLES20.glViewport(0, 0, edge, edge)
|
||||
prog.draw(oesTexId, stMatrix, camSize, lensFacing, sensorOrientation, true)
|
||||
w.swap()
|
||||
}
|
||||
if (frameCount == 0) {
|
||||
Log.i(
|
||||
tag,
|
||||
"first frame cam=$camSize orient=$sensorOrientation " +
|
||||
"facing=$lensFacing st=[${stMatrix.joinToString(",") { "%.2f".format(it) }}]",
|
||||
)
|
||||
}
|
||||
frameCount++
|
||||
if (recording) {
|
||||
recordWindow?.let { w ->
|
||||
w.makeCurrent()
|
||||
GLES20.glViewport(0, 0, edge, edge)
|
||||
prog.draw(oesTexId, stMatrix, camSize, lensFacing, sensorOrientation, false)
|
||||
w.setPresentationTime(st.timestamp)
|
||||
w.swap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun openCamera(result: MethodChannel.Result, textureId: Long) {
|
||||
manager().openCamera(
|
||||
cameraId,
|
||||
object : CameraDevice.StateCallback() {
|
||||
override fun onOpened(device: CameraDevice) {
|
||||
Log.i(tag, "camera opened $cameraId")
|
||||
cameraDevice = device
|
||||
startPreviewSession(result, textureId)
|
||||
}
|
||||
|
||||
override fun onDisconnected(device: CameraDevice) {
|
||||
device.close(); cameraDevice = null
|
||||
}
|
||||
|
||||
override fun onError(device: CameraDevice, error: Int) {
|
||||
device.close(); cameraDevice = null
|
||||
result.error("CAMERA_ERROR", "code $error", null)
|
||||
}
|
||||
},
|
||||
camHandler,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startPreviewSession(result: MethodChannel.Result, textureId: Long) {
|
||||
val device = cameraDevice ?: return
|
||||
val camSurface = camInputSurface ?: return
|
||||
try {
|
||||
createSession(listOf(camSurface)) { s ->
|
||||
session = s
|
||||
val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
||||
req.addTarget(camSurface)
|
||||
s.setRepeatingRequest(req.build(), null, camHandler)
|
||||
Log.i(tag, "preview session configured")
|
||||
result.success(mapOf("textureId" to textureId, "size" to edge))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "preview session failed", e)
|
||||
result.error("PREVIEW_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecorder(path: String) {
|
||||
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") MediaRecorder()
|
||||
}
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
rec.setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
||||
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
rec.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
||||
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
rec.setVideoSize(edge, edge)
|
||||
rec.setVideoEncodingBitRate(bitrate)
|
||||
rec.setVideoFrameRate(fps)
|
||||
rec.setAudioChannels(1)
|
||||
rec.setAudioSamplingRate(48000)
|
||||
rec.setAudioEncodingBitRate(96000)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
rec.setVideoEncodingProfileLevel(
|
||||
android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh,
|
||||
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "profile level: ${e.message}")
|
||||
}
|
||||
}
|
||||
rec.setOutputFile(path)
|
||||
rec.prepare()
|
||||
recorder = rec
|
||||
recorderSurface = rec.surface
|
||||
}
|
||||
|
||||
fun start(result: MethodChannel.Result) {
|
||||
if (cameraDevice == null || !glReady) {
|
||||
result.error("NOT_READY", "camera not initialized", null); return
|
||||
}
|
||||
try {
|
||||
val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath
|
||||
outputPath = path
|
||||
setupRecorder(path)
|
||||
val recSurface = recorderSurface!!
|
||||
glHandler!!.post {
|
||||
try {
|
||||
recordWindow = WindowSurface(egl!!, recSurface, egl!!.recordConfig)
|
||||
recorder?.start()
|
||||
recording = true
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "record window failed", e)
|
||||
result.error("START_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "start failed", e)
|
||||
result.error("START_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(result: MethodChannel.Result) {
|
||||
if (!recording) {
|
||||
result.error("NOT_RECORDING", "no active recording", null); return
|
||||
}
|
||||
recording = false
|
||||
glHandler!!.post {
|
||||
try {
|
||||
recordWindow?.release()
|
||||
recordWindow = null
|
||||
} catch (_: Exception) {}
|
||||
try {
|
||||
try {
|
||||
recorder?.stop()
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "recorder.stop: ${e.message}")
|
||||
}
|
||||
recorder?.reset(); recorder?.release(); recorder = null
|
||||
recorderSurface = null
|
||||
outputPath?.let { stripVideoEditList(it) }
|
||||
result.success(outputPath)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "stop failed", e)
|
||||
result.error("STOP_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MediaRecorder добавляет на видео-трек edit list (edts/elst) из-за
|
||||
// B-кадров — серверный валидатор такие файлы отвергает (у клиента видео без
|
||||
// edit list). Переименовываем бокс edts→free (тот же размер, ничего не
|
||||
// сдвигается, парсеры пропускают free) — edit list исчезает без перекода.
|
||||
private fun stripVideoEditList(path: String) {
|
||||
try {
|
||||
val f = java.io.RandomAccessFile(path, "rw")
|
||||
val scan = minOf(f.length(), 65536L).toInt()
|
||||
val buf = ByteArray(scan)
|
||||
f.seek(0); f.readFully(buf)
|
||||
var i = 0
|
||||
while (i + 8 <= scan) {
|
||||
if (buf[i] == 0x65.toByte() && buf[i + 1] == 0x64.toByte() &&
|
||||
buf[i + 2] == 0x74.toByte() && buf[i + 3] == 0x73.toByte()
|
||||
) {
|
||||
f.seek(i.toLong())
|
||||
f.write(byteArrayOf(0x66, 0x72, 0x65, 0x65))
|
||||
Log.i(tag, "stripped video edit list at $i")
|
||||
}
|
||||
i++
|
||||
}
|
||||
f.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "stripEditList: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
recording = false
|
||||
try { session?.close() } catch (_: Exception) {}
|
||||
session = null
|
||||
glHandler?.post {
|
||||
try { recordWindow?.release() } catch (_: Exception) {}
|
||||
try { recorder?.reset(); recorder?.release() } catch (_: Exception) {}
|
||||
recorder = null
|
||||
try { camTexture?.release() } catch (_: Exception) {}
|
||||
try { camInputSurface?.release() } catch (_: Exception) {}
|
||||
try { previewWindow?.release() } catch (_: Exception) {}
|
||||
try { program?.release() } catch (_: Exception) {}
|
||||
try { egl?.release() } catch (_: Exception) {}
|
||||
}
|
||||
cameraDevice?.close(); cameraDevice = null
|
||||
previewSurface?.release(); previewSurface = null
|
||||
flutterEntry?.release(); flutterEntry = null
|
||||
glThread?.quitSafely(); glThread = null; glHandler = null
|
||||
camThread?.quitSafely(); camThread = null; camHandler = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createSession(
|
||||
surfaces: List<Surface>,
|
||||
onReady: (CameraCaptureSession) -> Unit,
|
||||
) {
|
||||
val device = cameraDevice ?: return
|
||||
device.createCaptureSession(
|
||||
surfaces,
|
||||
object : CameraCaptureSession.StateCallback() {
|
||||
override fun onConfigured(s: CameraCaptureSession) = onReady(s)
|
||||
override fun onConfigureFailed(s: CameraCaptureSession) {
|
||||
Log.e(tag, "session config failed")
|
||||
}
|
||||
},
|
||||
camHandler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Минимальный EGL/GL под рендер OES-текстуры в квадратные surface ──
|
||||
|
||||
private class EglCore {
|
||||
val display: EGLDisplay
|
||||
val displayConfig: EGLConfig // без recordable — семплируется Flutter-текстурой
|
||||
val recordConfig: EGLConfig // с recordable — для MediaRecorder-surface
|
||||
val eglContext: EGLContext
|
||||
|
||||
init {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
val ver = IntArray(2)
|
||||
EGL14.eglInitialize(display, ver, 0, ver, 1)
|
||||
// Два конфига: превью (Flutter Texture) НЕ должно иметь
|
||||
// EGL_RECORDABLE_ANDROID — иначе буферы получают video-encoder usage
|
||||
// и не семплируются как картинка (чёрное превью). А encoder-surface
|
||||
// MediaRecorder, наоборот, требует recordable.
|
||||
displayConfig = chooseConfig(false)
|
||||
recordConfig = chooseConfig(true)
|
||||
val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
|
||||
eglContext = EGL14.eglCreateContext(
|
||||
display, displayConfig, EGL14.EGL_NO_CONTEXT, ctxAttribs, 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun chooseConfig(recordable: Boolean): EGLConfig {
|
||||
val attribs = if (recordable) {
|
||||
intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
0x3142, 1, EGL14.EGL_NONE,
|
||||
)
|
||||
} else {
|
||||
intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
}
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val num = IntArray(1)
|
||||
EGL14.eglChooseConfig(display, attribs, 0, configs, 0, 1, num, 0)
|
||||
return configs[0]!!
|
||||
}
|
||||
|
||||
fun release() {
|
||||
EGL14.eglMakeCurrent(
|
||||
display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT,
|
||||
)
|
||||
EGL14.eglDestroyContext(display, eglContext)
|
||||
EGL14.eglReleaseThread()
|
||||
EGL14.eglTerminate(display)
|
||||
}
|
||||
}
|
||||
|
||||
private class WindowSurface(
|
||||
private val core: EglCore,
|
||||
surface: Surface,
|
||||
config: EGLConfig,
|
||||
) {
|
||||
private var eglSurface: EGLSurface =
|
||||
EGL14.eglCreateWindowSurface(
|
||||
core.display, config, surface, intArrayOf(EGL14.EGL_NONE), 0,
|
||||
).also {
|
||||
if (it == EGL14.EGL_NO_SURFACE) {
|
||||
Log.w("VideoNoteRecorder", "eglCreateWindowSurface FAILED err=${EGL14.eglGetError()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun makeCurrent() {
|
||||
EGL14.eglMakeCurrent(core.display, eglSurface, eglSurface, core.eglContext)
|
||||
}
|
||||
|
||||
fun swap() {
|
||||
EGL14.eglSwapBuffers(core.display, eglSurface)
|
||||
}
|
||||
|
||||
fun setPresentationTime(ns: Long) {
|
||||
EGLExt14.setPresentationTime(core.display, eglSurface, ns)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
EGL14.eglDestroySurface(core.display, eglSurface)
|
||||
}
|
||||
}
|
||||
|
||||
private object EGLExt14 {
|
||||
fun setPresentationTime(display: EGLDisplay, surface: EGLSurface, ns: Long) {
|
||||
android.opengl.EGLExt.eglPresentationTimeANDROID(display, surface, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// Рисует OES-текстуру камеры в текущий квадратный surface, кропая центральный
|
||||
// квадрат и учитывая ориентацию сенсора + зеркало фронталки.
|
||||
private class OesProgram {
|
||||
private val vertexShader = """
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aTexCoord;
|
||||
uniform mat4 uStMatrix;
|
||||
uniform mat4 uTexCrop;
|
||||
varying vec2 vTex;
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vTex = (uTexCrop * uStMatrix * aTexCoord).xy;
|
||||
}
|
||||
"""
|
||||
private val fragmentShader = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vTex;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = vec4(texture2D(sTexture, vTex).rgb, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
private val program: Int
|
||||
private val aPosition: Int
|
||||
private val aTexCoord: Int
|
||||
private val uStMatrix: Int
|
||||
private val uTexCrop: Int
|
||||
private val uTexture: Int
|
||||
private val quad: FloatBuffer
|
||||
private val tex: FloatBuffer
|
||||
private val crop = FloatArray(16)
|
||||
|
||||
init {
|
||||
program = buildProgram(vertexShader, fragmentShader)
|
||||
aPosition = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord")
|
||||
uStMatrix = GLES20.glGetUniformLocation(program, "uStMatrix")
|
||||
uTexCrop = GLES20.glGetUniformLocation(program, "uTexCrop")
|
||||
uTexture = GLES20.glGetUniformLocation(program, "sTexture")
|
||||
quad = floatBuf(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
||||
tex = floatBuf(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f))
|
||||
}
|
||||
|
||||
fun createOesTexture(): Int {
|
||||
val ids = IntArray(1)
|
||||
GLES20.glGenTextures(1, ids, 0)
|
||||
val id = ids[0]
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, id)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
fun draw(
|
||||
texId: Int,
|
||||
st: FloatArray,
|
||||
camSize: Size,
|
||||
lensFacing: Int,
|
||||
sensorOrientation: Int,
|
||||
mirror: Boolean,
|
||||
) {
|
||||
// Кроп выполняется ПОСЛЕ stMatrix (в отображаемом пространстве), т.к.
|
||||
// stMatrix уже поворачивает кадр. Считаем отображаемые размеры с учётом
|
||||
// поворота сенсора (90/180 свопает оси) и режем длинную ось в квадрат.
|
||||
Matrix.setIdentityM(crop, 0)
|
||||
Matrix.translateM(crop, 0, 0.5f, 0.5f, 0f)
|
||||
val swap = (sensorOrientation % 180) != 0
|
||||
val dispW = (if (swap) camSize.height else camSize.width).toFloat()
|
||||
val dispH = (if (swap) camSize.width else camSize.height).toFloat()
|
||||
if (dispW >= dispH) {
|
||||
Matrix.scaleM(crop, 0, dispH / dispW, 1f, 1f)
|
||||
} else {
|
||||
Matrix.scaleM(crop, 0, 1f, dispW / dispH, 1f)
|
||||
}
|
||||
// Зеркало фронталки по горизонтали (естественное селфи) — только превью.
|
||||
if (mirror && lensFacing == CameraCharacteristics.LENS_FACING_FRONT) {
|
||||
Matrix.scaleM(crop, 0, -1f, 1f, 1f)
|
||||
}
|
||||
Matrix.translateM(crop, 0, -0.5f, -0.5f, 0f)
|
||||
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId)
|
||||
GLES20.glUniform1i(uTexture, 0)
|
||||
GLES20.glUniformMatrix4fv(uStMatrix, 1, false, st, 0)
|
||||
GLES20.glUniformMatrix4fv(uTexCrop, 1, false, crop, 0)
|
||||
GLES20.glEnableVertexAttribArray(aPosition)
|
||||
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 0, quad)
|
||||
GLES20.glEnableVertexAttribArray(aTexCoord)
|
||||
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 0, tex)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
GLES20.glDisableVertexAttribArray(aPosition)
|
||||
GLES20.glDisableVertexAttribArray(aTexCoord)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
GLES20.glDeleteProgram(program)
|
||||
}
|
||||
|
||||
private fun floatBuf(data: FloatArray): FloatBuffer {
|
||||
val bb = ByteBuffer.allocateDirect(data.size * 4).order(ByteOrder.nativeOrder())
|
||||
val fb = bb.asFloatBuffer()
|
||||
fb.put(data).position(0)
|
||||
return fb
|
||||
}
|
||||
|
||||
private fun buildProgram(vs: String, fs: String): Int {
|
||||
val v = compile(GLES20.GL_VERTEX_SHADER, vs)
|
||||
val f = compile(GLES20.GL_FRAGMENT_SHADER, fs)
|
||||
val p = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(p, v)
|
||||
GLES20.glAttachShader(p, f)
|
||||
GLES20.glLinkProgram(p)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetProgramiv(p, GLES20.GL_LINK_STATUS, status, 0)
|
||||
if (status[0] == 0) {
|
||||
val log = GLES20.glGetProgramInfoLog(p)
|
||||
GLES20.glDeleteProgram(p)
|
||||
throw RuntimeException("link failed: $log")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
private fun compile(type: Int, src: String): Int {
|
||||
val s = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(s, src)
|
||||
GLES20.glCompileShader(s)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, status, 0)
|
||||
if (status[0] == 0) {
|
||||
val log = GLES20.glGetShaderInfoLog(s)
|
||||
GLES20.glDeleteShader(s)
|
||||
throw RuntimeException("compile failed: $log")
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,5 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
|
||||
android.useAndroidX=true
|
||||
kotlin.incremental=false
|
||||
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||
|
||||
android.ndk.suppressMinSdkVersionError=21
|
||||
|
||||
Reference in New Issue
Block a user