feat(stories): публикация видео-историй + фикс TTL

This commit is contained in:
klockky
2026-07-24 15:31:50 +03:00
parent f7265b4fe8
commit b76da00f56
13 changed files with 723 additions and 96 deletions
@@ -233,13 +233,22 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"init" -> {
val front = call.argument<Boolean>("front") ?: true
val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer)
val size = call.argument<Int>("size") ?: 480
val fps = call.argument<Int>("fps") ?: 30
val rec = VideoNoteRecorder(
applicationContext,
flutterEngine.renderer,
size,
fps,
)
noteRecorder?.dispose()
noteRecorder = rec
rec.init(front, result)
}
"start" -> noteRecorder?.start(result)
?: result.error("NOT_READY", "recorder not initialized", null)
"switch" -> noteRecorder?.switchCamera(result)
?: result.error("NOT_READY", "recorder not initialized", null)
"stop" -> noteRecorder?.stop(result)
?: result.error("NOT_READY", "recorder not initialized", null)
"dispose" -> {
@@ -23,6 +23,7 @@ import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.util.Range
import android.util.Size
import android.view.Surface
import androidx.core.content.ContextCompat
@@ -35,22 +36,32 @@ import java.nio.FloatBuffer
// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный
// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит
// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264,
// одновременно в превью (Flutter Texture) и в MediaRecorder (квадрат, H.264,
// framework MediaMuxer). Так делает официальный клиент через CameraX — выход
// проходит серверный валидатор (media3-перекод его НЕ проходит).
// По умолчанию 480×480@30 как у официального клиента; размер и fps
// настраиваются из дев-меню.
class VideoNoteRecorder(
private val context: Context,
private val textureRegistry: TextureRegistry,
requestedEdge: Int = 480,
requestedFps: Int = 30,
) {
private val tag = "VideoNoteRecorder"
private val edge = 480
private val bitrate = 1_024_000
private val fps = 30
private val edge = requestedEdge.coerceIn(240, 1080)
private val fps = requestedFps.coerceIn(24, 60)
// 1 Мбит/с — базовый битрейт официального клиента для 480×480@30;
// масштабируем по площади кадра и частоте.
private val bitrate =
(1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt()
private var cameraId = ""
private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT
private var sensorOrientation = 270
private var camSize = Size(1280, 720)
private var fpsRange: Range<Int>? = null
private var hasOis = false
private var hasEis = false
private var cameraDevice: CameraDevice? = null
private var session: CameraCaptureSession? = null
@@ -95,23 +106,42 @@ class VideoNoteRecorder(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP,
)
camSize = pickCamSize(map)
fpsRange = pickFpsRange(
ch.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES),
)
hasOis = ch.get(
CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION,
)?.contains(
CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON,
) == true
hasEis = ch.get(
CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES,
)?.contains(
CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON,
) == true
return true
}
}
return false
}
// Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p.
// Поддерживаемый камерой размер вывода (для SurfaceTexture): короткая
// сторона не меньше edge, целимся в ближайший 16:9 (720p для 480/720,
// 1080p для 1080).
private fun pickCamSize(map: StreamConfigurationMap?): Size {
val targetShort = maxOf(720, edge)
val targetLong = targetShort * 16 / 9
val fallback = Size(targetLong, targetShort)
val sizes = map?.getOutputSizes(SurfaceTexture::class.java)
?: return Size(1280, 720)
var best = sizes.firstOrNull() ?: Size(1280, 720)
?: return fallback
var best = sizes.firstOrNull() ?: fallback
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)
val score = kotlin.math.abs(longSide - targetLong) +
kotlin.math.abs(shortSide - targetShort)
if (score < bestScore) {
bestScore = score
best = s
@@ -120,6 +150,18 @@ class VideoNoteRecorder(
return best
}
// Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps],
// иначе самый узкий диапазон, включающий fps; если 60 недоступно —
// максимально быстрый из имеющихся.
private fun pickFpsRange(ranges: Array<Range<Int>>?): Range<Int>? {
if (ranges == null || ranges.isEmpty()) return null
val covering = ranges.filter { it.lower <= fps && fps <= it.upper }
if (covering.isNotEmpty()) {
return covering.minByOrNull { (it.upper - it.lower) * 1000 + (fps - it.lower) }
}
return ranges.maxByOrNull { it.upper * 1000 - (it.upper - it.lower) }
}
fun init(facingFront: Boolean, rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
@@ -213,7 +255,7 @@ class VideoNoteRecorder(
recordWindow?.let { w ->
w.makeCurrent()
GLES20.glViewport(0, 0, edge, edge)
prog.draw(oesTexId, stMatrix, camSize, lensFacing, false)
prog.draw(oesTexId, stMatrix, camSize, lensFacing, true)
w.setPresentationTime(System.nanoTime())
w.swap()
}
@@ -252,8 +294,28 @@ class VideoNoteRecorder(
session = s
val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
req.addTarget(camSurface)
fpsRange?.let {
req.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
}
// Оптическая стабилизация, если линза умеет; иначе электронная.
// Обе сразу включать нельзя — на многих устройствах конфликтуют.
if (hasOis) {
req.set(
CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE,
CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON,
)
} else if (hasEis) {
req.set(
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON,
)
}
s.setRepeatingRequest(req.build(), null, camHandler)
Log.i(tag, "preview session configured")
Log.i(
tag,
"preview session configured fpsRange=$fpsRange " +
"ois=$hasOis eis=$hasEis",
)
result.success(mapOf("textureId" to textureId, "size" to edge))
}
} catch (e: Exception) {
@@ -283,7 +345,7 @@ class VideoNoteRecorder(
try {
rec.setVideoEncodingProfileLevel(
android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh,
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3,
avcLevel(),
)
} catch (e: Exception) {
Log.w(tag, "profile level: ${e.message}")
@@ -295,6 +357,56 @@ class VideoNoteRecorder(
recorderSurface = rec.surface
}
// Минимальный уровень AVC, вмещающий выбранные размер и fps
// (Level 3 — как у официального клиента для 480@30).
private fun avcLevel(): Int {
return when {
edge <= 480 && fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3
edge <= 720 && fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel31
edge <= 720 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel32
fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel4
else ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel42
}
}
// Смена камеры на лету (в т.ч. во время записи): GL-конвейер и
// MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия —
// кадры новой камеры продолжают приходить в тот же SurfaceTexture.
fun switchCamera(rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult)
if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return
}
val newFacing = if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) {
CameraCharacteristics.LENS_FACING_BACK
} else {
CameraCharacteristics.LENS_FACING_FRONT
}
try { session?.close() } catch (_: Exception) {}
session = null
try { cameraDevice?.close() } catch (_: Exception) {}
cameraDevice = null
if (!selectCamera(newFacing)) {
result.error("NO_CAMERA", "no camera for facing $newFacing", null)
return
}
val entry = flutterEntry
if (entry == null) {
result.error("NOT_READY", "texture released", null)
return
}
glHandler?.post {
camTexture?.setDefaultBufferSize(camSize.width, camSize.height)
}
Log.i(tag, "switching camera to $cameraId facing=$lensFacing cam=$camSize")
openCamera(result, entry.id())
}
fun start(result: MethodChannel.Result) {
if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return