uni-app x 双摄合拍 UTS:前后摄并发、GPU 合成与拍照录像实现

简介: `lf-dual-camera` 是一款专为 Android 设计的双摄协同拍摄插件,支持前后摄像头同步采集,实时合成画中画、左右/上下分屏画面,并叠加镜像、水印等效果;拍照录像均基于同一合成画布,确保双视角内容精准同步,输出即用。

需求是拍摄讲解视频时,后摄记录现场,前摄记录讲解者;做操作演示时,一路对着操作过程,另一路保留人物反应。需求最终需要一张同时包含两个视角的画面,并能把它保存成照片或视频。

lf-dual-camera 的处理方式是:前后摄分别提供图像,原生侧完成合成,预览和文件导出使用同一套布局。页面负责按钮、设置和状态提示,相机采集、图像处理及编码留在 Android 侧。

1. 要实现什么效果

合成画面提供三种布局:画中画、上下分屏、左右分屏。画中画可调整小窗位置和大小;分屏可互换主次摄像头。前摄镜像、圆角、文字和时间水印参与合成,导出的照片与视频也会保留这些设置。

实拍图位:三种布局对照

使用同一组前后摄场景,分别截取画中画、上下分屏、左右分屏,标明主摄和副摄。

图注:同一双摄输入在三种合成布局下的预览效果。

2. 为什么不能直接打开两个相机

手机有前摄和后摄,只能说明两者分别存在,不能说明它们可以同时输出。相机资源还受到系统实现、图像处理通道和输出流配置的约束。依次调用两次打开接口,第二次仍可能失败。

Android 从 API 30 提供并发组合查询。getConcurrentCameraIds() 返回允许并发配置的摄像头 ID 组合;插件从组合中筛选一颗后摄和一颗前摄,不假定 ID 一定是 0 和 1。Android CameraManager 文档 "Android CameraManager 文档")

下面是 CameraCapabilities.kt 中的组合筛选代码,省略了导入和类的其他成员。

fun pairs(): List<List<String>> {
   
    if (Build.VERSION.SDK_INT < 30) return emptyList()
    val pairs = ArrayList<List<String>>()
    for (combination in manager.concurrentCameraIds.sortedBy {
    it.sorted().joinToString(",") }) {
   
        val fronts = combination.sorted().filter {
    facing(it) == "front" }
        val backs = combination.sorted().filter {
    facing(it) == "back" }
        for (back in backs) for (front in fronts) pairs.add(listOf(back, front))
    }
    return pairs.distinct()
}

拿到组合后,还要检查目标画质对应的输入尺寸、帧时长及 H.264 Surface 编码能力。获得权限后,进一步调用 isConcurrentSessionConfigurationSupported() 检查会话配置;正式打开时,再用实际输入 Surface 检查一次。

因此,dualSupported、profiles 和打开结果分别回答不同问题:系统有没有声明前后摄并发组合、当前检测有哪些可用画质、这次打开是否成功。dualSupported: true 不能替代后两项检查。未授权时画质检测采用保守结果,授权后可以重新查询。

画质也不能只看某一颗摄像头能否输出 1080p。双路同时工作时,整组输出配置需要被系统接受,编码器还要支持合成后的尺寸和帧率。单摄支持的分辨率,不能直接当成双摄支持的分辨率。示例从 720p 开始,先确认基本链路,再依据检测结果开放更高画质。

不支持双摄时,页面应显示原因,再让使用者选择前摄或后摄。插件不会悄悄切换模式。这样用户看到的是自己选择的拍摄方式,业务也不会把单摄文件误认为双摄成片。

3. 双摄画面怎样进入页面

页面使用 lf-dual-camera-x 标准组件,组件内部放置 native-view。初始化时,UTS 将 Kotlin 创建的 TextureView 绑定到原生视图元素,Android 渲染的画面由这个载体显示。DCloud native-view 文档

图 1:页面通过 UTS 控制相机会话;两路图像在原生侧合成,再分别用于预览、照片和视频输出。

这里的职责分成三层。.uvue 页面管理交互和显示状态;UTS 对外提供参数类型、权限申请和回调;Kotlin 负责 Camera2 会话、OpenGL 渲染、编码与文件写入。UTS 可以直接调用插件目录里的 Kotlin 类,属于原生混编。DCloud UTS 原生混编说明

组件创建后通过 ready 返回 sessionId,后续操作用它定位相机会话。ready 表示预览载体已经建立,此时摄像头可能还没有打开。双摄打开成功还要等两路输入都至少收到首帧。

页面传递的是模式、画质、布局等控制数据,以及拍摄结果的路径和尺寸。摄像头图像不会逐帧转成 Base64,也不会经由 UTS 回调交给页面再拼接。这样页面交互不必承担连续图像数据的传输和处理。

这个插件在控制接口中使用 JSON 传递纯数据,成功和失败回调留在 UTS 层,不参与序列化。对外统一会话标识,也让拍照、录像和组件事件能够对应到同一个预览实例,避免多个页面之间误操作其他会话。

4. 两路画面怎样合成

4.1 每颗摄像头提供一条输入流

原生侧为每颗摄像头创建一张 OES 外部纹理,再用 SurfaceTexture 关联纹理,并把对应的 Surface 交给 Camera2。摄像头写入新图像后,只标记该输入有更新;读取纹理和绘制统一放到 GL 线程。

渲染循环读取两路最新可用画面,调用 updateTexImage(),同时取得该帧的纹理变换矩阵,然后把两路图像画进同一个 FBO。FBO 可以理解为一块不直接显示在屏幕上的输出画布,其尺寸由打开相机时的画质和方向决定。

合成调度的目标是 30fps。这是配置和调度目标,实际表现取决于设备。两路输入也没有按曝光时间配对,因此这里实现的是并发采集与合成,不能据此声称两颗传感器严格同步。

4.2 布局决定画到哪里,裁剪决定取哪一部分

pipRect 使用完整输出画布的归一化坐标,原点在左上角。假设输出为 720×1280,设置 x=0.60、y=0.10、width=0.30、height=0.30,小窗就在距左侧 432 像素、距顶部 128 像素的位置,区域为 216×384 像素。这组参数用于说明坐标,不是组件默认值。

上下分屏各占画布一半高度,左右分屏各占一半宽度。渲染器先绘制主画面,再绘制副画面,最后叠加水印。以下是 compose() 内部的布局分支节选,变量来自所在类及前面的摄像头选择过程。

if (primary != null) {
   
    if (secondary == null) drawCamera(primary, 0f, 0f, 1f, 1f, 0f)
    else when (appearance.optString("layout", "pip")) {
   
        "split-vertical" -> {
    drawCamera(primary, 0f, 0f, 1f, .5f, 0f); drawCamera(secondary, 0f, .5f, 1f, .5f, 0f) }
        "split-horizontal" -> {
    drawCamera(primary, 0f, 0f, .5f, 1f, 0f); drawCamera(secondary, .5f, 0f, .5f, 1f, 0f) }
        else -> {
   
            drawCamera(primary, 0f, 0f, 1f, 1f, 0f)
            val rect = appearance.optJSONObject("pipRect") ?: JSONObject()
            val width = rect.optDouble("width", .3).toFloat().coerceIn(.05f, 1f)
            val height = rect.optDouble("height", .225).toFloat().coerceIn(.05f, 1f)
            val x = rect.optDouble("x", .67).toFloat().coerceIn(0f, 1f - width)
            val y = rect.optDouble("y", .03).toFloat().coerceIn(0f, 1f - height)
            drawCamera(secondary, x, y, width, height,
                appearance.optDouble("radius", .025).toFloat().coerceIn(0f, .5f) * min(output.width, output.height))
        }
    }
}
drawWatermark()

摄像头输入与目标区域的宽高比通常不同。插件在每个区域内做等比居中裁剪:保留物体比例,裁掉超出区域的视野。把整个输入直接缩放到区域宽高,会把人物或圆形物体压扁。

图 2:小窗和分屏的位置以输出画布为准;摄像头图像在各自区域内居中裁剪,完整合成画面在预览容器内等比显示。

还要区分两种适配。摄像头进入合成区域时采用裁剪;完整合成画面进入页面容器时采用等比完整显示,容器比例不同可能出现黑边。接入时可以按下面三项区分设置对象。

设置 影响对象 是否改变输出文件宽高
组件样式宽高 页面里的预览区域 否
layout、pipRect 合成画面中的区域和位置 否
profile、orientation 合成输出尺寸与方向 是

点按对焦同样要使用这套坐标关系。触点先从预览区域换算到输出画布,再确定落在哪颗摄像头的区域内,随后逆向处理裁剪、旋转和镜像,映射到传感器测光区域。直接把屏幕坐标除以页面宽高传给相机,在分屏或小窗里就可能对错位置。

5. 在 uni-app x 中接入

将插件放入工程的 uni_modules/lf-dual-camera,新增 .uvue 页面,并在 pages.json 注册。示例按从业务首页进入的二级页面使用,后面的返回处理依赖这一层页面栈。页面固定使用 720p 竖屏输出,先检测能力,再由按钮选择双摄、前摄或后摄。

<template>
  <view>
    <!-- #ifdef APP-ANDROID -->
    <lf-dual-camera-x style="width:100%;height:360px"
      @ready="onCameraReady" @statechange="onCameraState"
      @error="onCameraError" />
    <text>{
   {
    notice }}</text>
    <button :disabled="detecting || opening || closing || opened" @click="detect">检测设备</button>
    <button :disabled="!canOpen || !dualSupported" @click="openPreview('dual')">打开双摄</button>
    <button :disabled="!canOpen" @click="openPreview('back')">打开后摄</button>
    <button :disabled="!canOpen" @click="openPreview('front')">打开前摄</button>
    <button :disabled="closing || (!opened && !opening)" @click="closePreview">关闭相机</button>
    <!-- #endif -->
    <!-- #ifndef APP-ANDROID -->
    <text>此示例仅支持 Android App。</text>
    <!-- #endif -->
  </view>
</template>

<script setup lang="uts">
import LfDualCameraX from '@/uni_modules/lf-dual-camera/components/lf-dual-camera-x/lf-dual-camera-x.uvue'
import {
    getCapabilities, openCamera, closeCamera } from '@/uni_modules/lf-dual-camera'
import type {
    LfCameraOptions, LfCameraCapabilitiesOptions, LfCameraError } from '@/uni_modules/lf-dual-camera'

const sessionId = ref('')
const ready = ref(false)
const dualSupported = ref(false)
const detecting = ref(false)
const opening = ref(false)
const closing = ref(false)
const opened = ref(false)
const notice = ref('先检测设备,再选择拍摄模式。')
let pageVisible = true
let disposed = false
let closeRequested = false
const canOpen = computed((): boolean => sessionId.value.length > 0 && ready.value &&
  !detecting.value && !opening.value && !closing.value && !opened.value)

function showError(error: LfCameraError): void {
   
  if (!disposed) notice.value = error.code + ':' + error.message
}
function closeNative(id: string): void {
   
  if (id.length == 0 || closing.value) return
  if (!disposed) closing.value = true
  const options: LfCameraOptions = {
    sessionId: id,
    success: (_) => {
    if (!disposed) opened.value = false },
    fail: (error) => showError(error),
    complete: () => {
    if (!disposed) closing.value = false }
  }
  closeCamera(options)
}
function closePreview(): void {
   
  closeRequested = true
  closeNative(sessionId.value)
}
function onCameraReady(event: UniNativeViewEvent): void {
   
  const id = ((event.detail as UTSJSONObject)['sessionId'] as string | null) ?? ''
  if (disposed) {
    closeNative(id); return }
  sessionId.value = id
}
function onCameraState(event: UniNativeViewEvent): void {
   
  if (disposed) return
  const state = ((event.detail as UTSJSONObject)['state'] as string | null) ?? ''
  if (state == 'previewing') {
   
    if (closeRequested || !pageVisible) closePreview()
    else opened.value = true
  }
  if (state == 'closed' || state == 'error') opened.value = false
}
function onCameraError(event: UniNativeViewEvent): void {
   
  const detail = event.detail as UTSJSONObject
  showError({
    code: (detail['code'] as string | null) ?? 'UNKNOWN',
    message: (detail['message'] as string | null) ?? '相机暂不可用' })
}
function detect(): void {
   
  if (disposed || detecting.value || opened.value || opening.value || closing.value) return
  // #ifdef APP-ANDROID
  ready.value = false
  detecting.value = true
  const options: LfCameraCapabilitiesOptions = {
   
    success: (data) => {
   
      if (disposed) return
      dualSupported.value = data.dualSupported
      ready.value = true
      notice.value = data.dualSupported ? '系统提供双摄组合,仍需实际打开检查。' : data.reason
    },
    fail: (error) => showError(error),
    complete: () => {
    if (!disposed) detecting.value = false }
  }
  getCapabilities(options)
  // #endif
}
function openPreview(mode: string): void {
   
  if (disposed || !pageVisible || !canOpen.value || (mode == 'dual' && !dualSupported.value)) return
  const id = sessionId.value
  closeRequested = false
  opening.value = true
  const options: LfCameraOptions = {
    sessionId: id, mode, profile: '720p', orientation: 'portrait',
    success: (_) => {
   
      if (disposed || closeRequested || !pageVisible) {
    closeNative(id); return }
      opened.value = true
      notice.value = '预览已打开:' + mode
    },
    fail: (error) => showError(error),
    complete: () => {
    if (!disposed) opening.value = false }
  }
  openCamera(options)
}
onShow(() => {
    pageVisible = true })
onHide(() => {
    pageVisible = false; closePreview() })
onUnload(() => {
    pageVisible = false; closePreview(); disposed = true })
</script>

代码把组件就绪、能力检测和相机打开分开处理。只有取得会话且能力检测成功,才允许发起打开;双摄按钮还需要 dualSupported 为真。实际打开失败时仍显示错误,不因检测通过而忽略失败回调。

页面隐藏后关闭相机,返回时需要手动重新打开。如果授权弹窗或打开请求尚未结束,页面就已隐藏,迟到的打开成功回调也会触发关闭,避免后台继续占用相机。

权限按操作申请:打开预览需要相机权限,有声录像再申请麦克风权限,无声录像不采集麦克风。拒绝麦克风权限后,应说明原因并由使用者明确选择无声模式。Android Manifest 中的权限声明仍需随原生配置合并,不能只检查页面里的请求代码。

需要改变布局时,传入组件的 layout、mainCamera 和 pipRect 即可。绑定了自定义 pipRect 并允许拖动时,应通过 pipchange 回写位置,避免下一次属性更新又恢复旧值。完整 Demo 中的缩放、对焦、补光按钮,还需根据检测到的摄像头能力决定是否开放。

6. 拍照、录像与保存相册

6.1 照片从合成画布读取

拍照时,渲染器先应用当前布局,随后通过 glReadPixels() 从共享 FBO 读取 RGBA 像素。读取后交给独立线程,完成上下行翻转、颜色通道转换和 JPEG 编码,处理 OpenGL 像素与 Android Bitmap 的存储差异。

makeCurrent(pbuffer)
// Render pending appearance changes before reading the shared composite.
compose()
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebuffer)
val pixels = ByteBuffer.allocateDirect(output.width * output.height * 4).order(ByteOrder.nativeOrder())
GLES20.glReadPixels(0, 0, output.width, output.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixels)
checkGl("Read photo")
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)

这样得到的是当前 720p 或 1080p 合成画布的快照,包含布局、镜像和水印,并非两颗传感器各自的全像素照片。这里存在 GPU 到 CPU 的像素回读,也有 Bitmap 编码,不能把整个拍照过程称为零拷贝。

6.2 视频把同一张合成纹理送入编码器

录像使用 MediaCodec 的 Surface 输入。GL 线程切换到编码窗口,将合成纹理再画一次,设置视频时间戳并提交;编码线程取出 H.264 数据,由 MediaMuxer 写入 MP4。页面按钮、对焦框等界面元素没有进入合成画布,因此不会被录进成片。

val active = recorder
if (active != null && encoderEgl != EGL14.EGL_NO_SURFACE) {
   
    makeCurrent(encoderEgl)
    GLES20.glViewport(0, 0, output.width, output.height)
    drawTexture(compositeTexture, normalUv, false)
    EGLExt.eglPresentationTimeANDROID(display, encoderEgl, max(0L, System.nanoTime() - active.startedAtNs))
    if (!EGL14.eglSwapBuffers(display, encoderEgl)) throw IllegalStateException("Video surface swap failed")
}

有声录像另用 AudioRecord 采集 44.1kHz 单声道 PCM,编码为 AAC。视频时间戳相对统一的单调时钟起点计算;音频优先通过 AudioTimestamp 映射到同一起点,无法取得时按累计采样帧数计算。每条轨道写入时还要保证时间戳递增。这是时间轴的处理方式,长时间音画同步仍需通过真机录制检查。

稳定录制阶段允许拍照;录像启动和停止阶段不允许。录制时可以调整布局、镜像和水印,输出方向、画质、单双摄模式和额外旋转则需要先停录。

停止按钮也不是直接关闭文件。原生侧先结束编码输入,继续取出尚未输出的压缩数据,等音视频轨道收尾并完成 MP4 封装,才返回文件结果。按钮应在这段时间保持停止中状态,避免连续点击启动下一次录制,或者把未完成文件交给播放器。

6.3 生成文件与保存相册分开处理

takePhoto 和 stopRecording 成功后得到应用私有文件。需要出现在系统相册中,还要调用 saveToAlbum。Android 10 及以上通过 MediaStore 写入,使用 IS_PENDING 标记未完成文件,写完后再发布;Android 6–9 保存相册时需要存储权限。Android 共享媒体存储说明

在第 5 节页面中加入拍摄功能,先把这组 API 补到原来的导入区:

import {
    takePhoto, startRecording, stopRecording, saveToAlbum } from '@/uni_modules/lf-dual-camera'

将下面代码从 const recordStage 开始,整体替换原来的 closePreview 函数,其他函数和生命周期保留。这个位置能保证相关局部函数在首次使用前已经声明。

const recordStage = ref('idle') // idle / starting / recording / stopping
const photoBusy = ref(false)
const failedPaths = ref<string[]>([])
const handledPaths: string[] = []
const savingPaths: string[] = []
let stopCallbacks: (() => void)[] = []
let stopRequested = false
let lastStopFailed = false

function savePath(path: string): void {
  if (path.length == 0 || savingPaths.includes(path)) return
  savingPaths.push(path)
  saveToAlbum({
    path,
    success: (_) => {
    if (!disposed) notice.value = '已保存到系统相册。' },
    fail: (error) => {
   
      if (!disposed) {
   
        if (!failedPaths.value.includes(path)) failedPaths.value.push(path)
        showError(error)
      }
      console.log('相册保存失败,原文件保留:', path)
    },
    complete: () => {
   
      const index = savingPaths.indexOf(path)
      if (index >= 0) savingPaths.splice(index, 1)
    }
  })
}
function acceptFile(path: string): void {
   
  if (path.length == 0 || handledPaths.includes(path)) return
  handledPaths.push(path) // 两个完成入口共用此处,先登记再异步保存。
  savePath(path)
}
function retryFailed(): void {
   
  const paths = failedPaths.value.slice()
  failedPaths.value = []
  paths.forEach((path: string) => savePath(path)) // 手动重试不走 handledPaths。
}
function capturePhoto(): void {
   
  if (disposed || !pageVisible || !opened.value || closeRequested || photoBusy.value ||
      recordStage.value == 'starting' || recordStage.value == 'stopping') return
  photoBusy.value = true
  takePhoto({
    sessionId: sessionId.value,
    success: (file) => acceptFile(file.path), fail: (error) => showError(error),
    complete: () => {
    if (!disposed) photoBusy.value = false }
  })
}
function flushStopCallbacks(): void {
   
  const callbacks = stopCallbacks
  stopCallbacks = []
  callbacks.forEach((done: () => void) => done())
}
function finishRecording(done: (() => void) | null = null): void {
   
  if (done != null) stopCallbacks.push(done)
  if (stopRequested || recordStage.value == 'starting') return
  if (recordStage.value == 'idle') {
    flushStopCallbacks(); return }
  stopRequested = true
  lastStopFailed = false
  if (!disposed) recordStage.value = 'stopping'
  stopRecording({
    sessionId: sessionId.value,
    success: (file) => {
    lastStopFailed = false; acceptFile(file.path) },
    fail: (error) => {
    lastStopFailed = true; showError(error) },
    complete: () => {
   
      stopRequested = false
      if (!disposed) recordStage.value = 'idle'
      flushStopCallbacks()
    }
  })
}
function beginRecording(): void {
   
  if (disposed || !pageVisible || !opened.value || closeRequested || photoBusy.value || recordStage.value != 'idle') return
  recordStage.value = 'starting'
  lastStopFailed = false
  const id = sessionId.value
  startRecording({
    sessionId: id, audio: true,
    success: (_) => {
   
      if (disposed) {
   
        stopRecording({
    sessionId: id,
          success: (file) => {
    lastStopFailed = false; acceptFile(file.path) },
          fail: (error) => {
    lastStopFailed = true; showError(error) },
          complete: () => flushStopCallbacks() })
        return
      }
      recordStage.value = 'recording'
      if (!pageVisible || closeRequested || stopCallbacks.length > 0) finishRecording()
    },
    fail: (error) => {
   
      lastStopFailed = true
      if (!disposed) recordStage.value = 'idle'
      showError(error)
      flushStopCallbacks()
    }
  })
}
function onRecordComplete(event: UniNativeViewEvent): void {
   
  const detail = event.detail as UTSJSONObject
  const result = (detail['result'] as UTSJSONObject | null) ?? detail
  acceptFile((result['path'] as string | null) ?? '')
  if (!disposed && !stopRequested) recordStage.value = 'idle'
}
function onRecordingState(event: UniNativeViewEvent): void {
   
  if (disposed || stopRequested || recordStage.value == 'starting') return
  const state = ((event.detail as UTSJSONObject)['state'] as string | null) ?? ''
  if (state == 'idle') recordStage.value = 'idle'
}

// 用本函数替换第 5 节的 closePreview;不要保留两个同名函数。
function closePreview(): void {
   
  closeRequested = true
  const id = sessionId.value
  finishRecording(() => closeNative(id))
}

// 主动返回时,先接收停录结果;相册保存仍可能在异步执行。
let allowBack = false
let leaving = false
onBackPress((_) : boolean => {
   
  if (allowBack) return false
  if (leaving) return true
  if (recordStage.value == 'idle') return false
  leaving = true
  finishRecording(() => {
   
    if (lastStopFailed) {
    leaving = false; return }
    allowBack = true
    uni.navigateBack()
  })
  return true
})

把原来的组件标签替换为下面的标签,增加录制事件;四个拍摄按钮放在原按钮之后,仍保留在 Android 条件编译块内。

<lf-dual-camera-x style="width:100%;height:360px"
  @ready="onCameraReady" @statechange="onCameraState"
  @error="onCameraError" @recordcomplete="onRecordComplete"
  @recordingchange="onRecordingState" />
<button :disabled="!opened || photoBusy || recordStage == 'starting' || recordStage == 'stopping'"
  @click="capturePhoto">拍照</button>
<button :disabled="!opened || photoBusy || recordStage != 'idle'"
  @click="beginRecording">开始有声录像</button>
<button :disabled="recordStage != 'recording'" @click="finishRecording()">停止录像</button>
<button :disabled="failedPaths.length == 0" @click="retryFailed">重试保存相册</button>

停止录像的成功回调与 recordcomplete 事件可能报告同一个文件,因此自动保存入口按 path 去重。保存失败时保留原文件,手动重试应绕过“已经自动处理过”的判断;同时阻止正在保存的路径被重复提交。

示例在主动返回时先等待停录结果,失败则留页显示错误;切后台或卸载时,无论停录成功与否,都继续清理相机资源。closeCamera.success 只代表关闭流程完成,不能替代成片结果。是否还要等待相册保存,由页面的退出流程明确决定,不能在保存请求刚发出时就提示“已保存”。

实拍图位:导出照片与视频帧

拍摄带水印的同一场景,展示 JPEG 和 MP4 的对应画面。

图注:预览、照片和录像使用相同的合成布局,页面操作栏不写入成片。

7. 方向、比例和退出问题怎么处理

7.1 不要把传感器方向重复应用

相机画面横转时,直接补一个 90° 旋转可能只修好当前机型。Camera2 输入的 SurfaceTexture 矩阵已经包含生产者侧的方向等变换,再叠加完整传感器旋转,可能出现重复处理。

当前实现先计算最终需要的裁剪、镜像和方向映射,再抵消预计已存在于输入中的变换,形成附加 UV。着色器仍乘每帧真实的 SurfaceTexture 矩阵,保留生产者的裁剪和边缘修正。这个处理参考了 CameraX SurfaceOutputImpl 的实现。

用矩阵关系表示,附加变换为 predictedST⁻¹ × desired。其中 desired 包含目标方向、裁剪和镜像;predictedST 是预计已经存在于相机输入中的变换。CameraMath.textureCoordinates() 抵消预计方向的关键片段如下,sensor 是前面计算出的目标采样点。

// Invert predictedST = verticalFlip * rotate(sensor) * mirror(front).
val u = sensor.x
val v = 1.0 - sensor.y
val unrotated = when (((input.sensorOrientation % 360) + 360) % 360) {
   
    90 -> Point(v, 1.0 - u)
    180 -> Point(1.0 - u, 1.0 - v)
    270 -> Point(1.0 - v, u)
    else -> Point(u, v)
}
coordinates[i * 2] = (if (front) 1.0 - unrotated.x else unrotated.x).toFloat()
coordinates[i * 2 + 1] = unrotated.y.toFloat()

顶点着色器随后仍执行 uTransform * vec4(aTexCoord, 0.0, 1.0),其中 uTransform 来自当前帧真实的 SurfaceTexture 矩阵。

检查方向时可分别让前后摄拍摄正立文字,检查比例时使用圆形物体。前摄镜像开启后,文字左右翻转是镜像结果;文字横倒、圆形变椭圆,则需要继续检查方向矩阵或裁剪计算。

实拍图位:方向与比例检查

使用文字卡和圆形物体,覆盖前后摄、镜像开关与至少一种分屏。

图注:通过文字方向与圆形轮廓检查旋转、镜像和等比裁剪。未取得修复前截图时,不制作前后对比。

7.2 关闭相机需要等待原生回调

离开页面或返回上一页时,Camera2 的打开、录像收尾和照片写入可能仍在执行。原生层通过 generation 标识失效的打开流程;关闭时先结束录像、等待未完成照片,再关闭 CaptureSession 和 CameraDevice,确认设备关闭后释放输入 Surface。

页面关闭相机与组件销毁也有区别。前者保留预览载体,方便再次打开;组件卸载才释放绑定和会话。Vapor 中不把原生元素保存后再传回桥接入口,而是传字符串 bindingId,由原生侧查找并释放绑定,避免对象跨运行环境后的类型变化。

业务页面使用公开的关闭接口即可,不需要复制组件内部的绑定清理代码。当前不支持后台录像,切后台、返回、拒绝权限和相机被占用,都应列入实际设备的验收场景。

相关文章
|
云安全 安全 网络安全
|
7天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
1997 1
|
14天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
2964 0
|
13天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2203 44
|
10天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
1398 3
|
7天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
7天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
1598 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
|
6天前
|
人工智能 Linux API
Magpie, 轻量本地AI网关!免费Token用不完!
Magpie 是开源AI编程模型调度网关,上线一月获1.8万Star。它通过本地高性能网关统一托管多平台API密钥、账号与免费额度,实现跨Agent的模型秒级切换、静默额度轮换与资源复用,显著降低碎片化管理成本。(239字)
Magpie, 轻量本地AI网关!免费Token用不完!
|
6天前
|
安全 程序员 应用服务中间件
PHP 被曝两大致命漏洞,请所有程序员抓紧做这件事!
PHP 官方发布了紧急安全更新,一口气修复了两个严重漏洞。如果网站和服务器用了 PHP 应该怎么处理?漏洞的原理是什么?本文给你讲清楚
678 0

热门文章

最新文章