AR 眼镜之-相册同步-技术方案

目录

[📂 前言](#📂 前言)

[🔱 1. 相册同步架构](#🔱 1. 相册同步架构)

[1.1 数据源与编码](#1.1 数据源与编码)

[1.2 通道分工](#1.2 通道分工)

[⚡ 2. 相册同步时序图](#⚡ 2. 相册同步时序图)

[💠 3. 连接方案:Phone GO](#💠 3. 连接方案:Phone GO)

[3.1 Phone GO 方案特点](#3.1 Phone GO 方案特点)

[3.2 入口与触发](#3.2 入口与触发)

[⚛️ 4. 相册同步传输实现](#⚛️ 4. 相册同步传输实现)

[4.1 状态机](#4.1 状态机)

[4.2 会话建立](#4.2 会话建立)

[4.3 文件拉取流程](#4.3 文件拉取流程)

[4.4 关键代码](#4.4 关键代码)

[4.5 .avpk 容器格式解析](#4.5 .avpk 容器格式解析)

[4.6 视频转码 MP4](#4.6 视频转码 MP4)

[4.7 存储路径](#4.7 存储路径)

[4.8 异常处理](#4.8 异常处理)

[📂 小结](#📂 小结)


📂 前言

上一篇《AR 眼镜之-直播-技术方案》聊的是实时音视频推流,核心场景是"眼镜采集---手机中转---云端分发",但眼镜还有一个高频日常场景------相册

当用户把眼镜戴上头,随手拍张照片、录段视频,但是眼镜屏幕小,甚至没屏幕,怎么查看?怎么导到手机里发朋友圈?总不能每次都掏出 Type-C 线吧,所以相册功能的核心诉求很直白:手机打开 App 相册 → 发现眼镜有新照片/视频 → 拉过来存到 sdcard → 直接在手机上查看。

|------|-----------------------|
| 触发方式 | 手机 App 主动查询,有更新才同步 |
| 传输方式 | 采用 P2P 直连,Socket 文件拉取 |
| 落地方式 | 图片直存,视频转码 MP4 后保存 |
| 编码格式 | H.265 + AAC |

🔱 1. 相册同步架构

1.1 数据源与编码

|----------|--------------|---------------|
| 数据类型 | 编码格式 | 存储形式 |
| 照片 | JPEG | .jpg 独立文件 |
| 视频画面 | H.265 (HEVC) | 封装在 .avpk 容器内 |
| 视频音频 | AAC | 封装在 .avpk 容器内 |

1.2 通道分工

|-----------|------------------------|
| 通道 | 职责 |
| 蓝牙 (BT) | 信令控制、相册查询、WiFi 唤醒、状态通知 |
| Wi-Fi P2P | 文件传输(高带宽) |

⚡ 2. 相册同步时序图

💠 3. 连接方案:Phone GO

3.1 Phone GO 方案特点

手机作为 GO(Group Owner)创建 P2P 网络,眼镜作为 GC 主动加入。

|---------------|-------------------------|
| 维度 | 说明 |
| GO 角色 | 手机 |
| 建网方式 | 手机 createGroup(),等待眼镜连接 |
| discoverPeers | 否 |
| Wi-Fi 参数来源 | 手机生成,蓝牙下发 |
| 建连速度 | 快(无扫描) |
| 稳定性 | 高 |
| 适用场景 | 手机作为数据中心 |

3.2 入口与触发

1)CmdV2GetCameraAlbumReq:手机 App 进入相册页面检查蓝牙状态,已连接则查询相册更新

复制代码
private fun checkConnectState() {
    val isConnect = DeviceManager.getInstance().isConnected()
    if (!isConnect) {
        state = State.BLUETOOTH_CONNECT
    } else {
        if (FileTransmissionTask.getInstance().transferring) {
            state = State.SYNCING
        } else {
            DeviceManager.getInstance().sendCmd(CmdV2GetCameraAlbumReq())
        }
    }
}

2)CmdV2GetCameraAlbumRsp:眼镜回传照片/视频数量,有更新则切到 SYNC 状态并展示同步按钮

复制代码
if (cmd.bizType == BizType.BIZ_CAMERA_GROUP && cmd.cmdV2Type == MBCmdV2Type.CMD_CAMERA_GET_ALBUM.toInt()) {
  val cameraInfo = (cmd as CmdV2GetCameraAlbumRsp).value
  val total = if (cameraInfo == null) {
      logI(TAG, "cameraInfo == null")
      0
  } else {
      cameraInfo.image + cameraInfo.video
  }
  logI(TAG, "CmdRspMsg sync total = = $total synchronizedQuantity = ${mViewModel.synchronizedQuantity}")
  if (state == State.SYNCING) {
      mViewModel.totalSyncQuantity = total + mViewModel.synchronizedQuantity
  } else {
      mViewModel.totalSyncQuantity = total
      mViewModel.synchronizedQuantity = 0
      if ((state == State.DEFAULT || state == State.SYNC_SUCCESS) && total != 0) {
          state = State.SYNC
      } else if (state == State.SYNC) {
          syncView?.findViewById<TextView>(R.id.syncQuantity)?.text = this.resources.getString(R.string.gallery_sync_file, mViewModel.totalSyncQuantity.toString())
      }
  }
  updateSyncingUI()
}

3)点击同步 :前置检查通过后,蓝牙 CmdV2WifiONReq 唤醒 WiFi,同时设置并监听 EasyNet 状态

复制代码
// 前置检查
private fun requirementJudgeChain(): Boolean {
    if (FileUtils.isExternalStorageLow()) return false   // 存储不足
    if (!hasFilePermission()) { requestFilePermission(); return false }
    if (!wifiManager.isWifiEnabled) { state = State.WIFI_CONNECT; return false }
    return true
}

// 蓝牙 sendWifiOn 唤醒 WiFi
DeviceManager.getInstance().sendCmd(
    CmdV2WifiONReq(
        BizType.CMD_CONNECT,
        cmdType,
        null,
        wifiType,
        support5g,
        channel,
        ssid,
        password
    )
)

// 设置并监听 EasyNet 状态
EasyNetManager.getInstance().addEasyNetConnectState(this)
EasyNetManager.getInstance().addEasyNetMessageArrive(this)
EasyNetManager.getInstance().addWifiP2pConnectState(this)
EasyNetManager.getInstance().addMessageOperationComplete(this)
EasyNetManager.getInstance().addEasynetFragmentTracker(this)
EasyNetManager.getInstance().addFilePullProgress(filePullProgress)
DeviceManager.getInstance().addBTCmdReqListener(this)

注: 更多 P2P 细节可参考上一篇《AR 眼镜之-直播-技术方案》第三章节的 Wi-Fi P2P 连接方案讨论部分。

⚛️ 4. 相册同步传输实现

4.1 状态机

|-------------------|-------------|
| 状态 | 说明 |
| DEFAULT | 默认空闲 |
| BLUETOOTH_CONNECT | 蓝牙未连接 |
| WIFI_CONNECT | WiFi 未开启 |
| CONNECTING | 正在建立 P2P |
| CONNECT_FAILED | 建连失败 |
| SYNC | 有新文件,等待用户同步 |
| SYNCING | 正在拉取文件 |
| SYNC_SUCCESS | 同步完成 |

4.2 会话建立

EasyNet 会话激活后,启动同步请求 AlbumSyncRequestCmdV2CameraAlbumSyncStatusReq

复制代码
override fun onSessionActive(session: EasynetSession) {
    if (isSyncPicture) {
        LiveEventBus.get<String>(GALLERY_CONNECT_GLASS_SUCCESS).post("")
        syncFileList.clear()
        currentSyncIndex = 0
        currentMessage = AlbumSyncRequest(AlbumType(MediaType.ALL))
        EasyNetManager.getInstance().sendMessage(currentMessage as AlbumSyncRequest, 5000)
        DeviceManager.getInstance().sendCmd(CmdV2CameraAlbumSyncStatusReq(1))
        transferring = true
    }
}

4.3 文件拉取流程

4.4 关键代码

1)收到文件列表,逐个拉取

复制代码
override fun onMessageArrived(msg: Message) {
    when (msg.header.opcode) {
        Opcode.ALBUM_SYNC -> {
            val albumList = (msg as Response).body as AlbumList
            albumList.all.forEach {
                if (!syncFileList.contains(it.get())) {
                    syncFileList.add(it.get())
                }
            }
            if (syncFileList.isNotEmpty() && currentSyncIndex < syncFileList.size) {
                currentMessage = FilePullRequest(FileUuid(syncFileList[currentSyncIndex]))
                EasyNetManager.getInstance().sendMessage(currentMessage as FilePullRequest)
                currentSyncIndex++
            }
        }
    }
}

2)分片回调,按文件类型处理

复制代码
override fun onComplete(session: Int) {
    fragmentFileOutputStream?.close()
    fragmentFile?.let { f ->
        when (fragmentFileType) {
            FileType.FT_IMAGE -> {
                addPictureBytes(f.readBytes(), fragmentTimestampMs)
                f.delete()
            }
            FileType.FT_VIDEO -> {
                if (AvpkParser.isAvpkFile(f)) {
                    muxerAvpkToMp4(f, fragmentTimestampMs)  // 新格式
                } else {
                    VideoDataParse.parse(f.readBytes(), fragmentGroup.toString())
                    muxerMp4(f.lastModified(), fragmentGroup)  // 旧格式
                }
                f.delete()
            }
        }
    }
    // ... 更新 UI
    
    syncNext()
}

3)进度回调

复制代码
private val filePullProgress = object : EasyNetManager.FilePullProgress {
    override fun onOperationProgress(progress: Float) {
        val p = progress.coerceIn(0f, 100f)
        // 变化小于 1% 不上报,减少 UI 刷新
        if (p < 100f && lastPostedPullProgress >= 0f && p - lastPostedPullProgress < 1f) return
        mainHandler.post { postFilePullProgress(p) }
    }
}

4.5 .avpk 容器格式解析

复制代码
object AvpkParser {

    private const val TAG = "AvpkParser"

    private const val ISP_AVPK_MAGIC: Int = 0x4b505641 // 'AVPK' little-endian
    private const val ISP_AVPK_VERSION_V1: Int = 1
    private const val ISP_AVPK_NAME_MAX: Int = 64

    private const val FOOTER_HDR_SIZE = 4 + 2 + 2
    private const val ENTRY_SIZE = 1 + 3 + 8 + 8 + ISP_AVPK_NAME_MAX
    private const val TRAILER_SIZE = 4 + 4

    data class UnpackResult(
        val h265File: File,
        val aacFile: File
    )

    fun isAvpkFileName(fileName: String): Boolean {
        return fileName.endsWith(".avpk", ignoreCase = true)
    }

    fun isAvpkFile(file: File): Boolean = isAvpkFileName(file.name)

    /**
     * 将 .avpk 解包到 [outDir],按 entry [name] 写出;用后缀 `.h265` / `.aac` 区分视频轨与音频轨。
     *
     * @return 成功时返回各轨临时文件;失败返回 null。
     */
    fun unpackToDir(avpkFile: File, outDir: File): UnpackResult? {
        if (!avpkFile.isFile || !avpkFile.exists()) {
            logE(TAG, "unpackToDir: bad file")
            return null
        }
        if (!outDir.exists() && !outDir.mkdirs()) {
            logE(TAG, "unpackToDir: mkdirs failed ${outDir.path}")
            return null
        }

        val size = avpkFile.length()
        if (size < TRAILER_SIZE.toLong()) {
            logE(TAG, "unpackToDir: file too small")
            return null
        }

        RandomAccessFile(avpkFile, "r").use { raf ->
            raf.seek(size - TRAILER_SIZE)
            val trBuf = ByteArray(TRAILER_SIZE)
            raf.readFully(trBuf, 0, TRAILER_SIZE)
            val trBb = ByteBuffer.wrap(trBuf).order(ByteOrder.LITTLE_ENDIAN)
            val trMagic = trBb.int
            val footerLen = trBb.int.toLong() and 0xffffffffL
            logI(TAG, "unpackToDir trMagic=$trMagic footerLen=$footerLen")
            if (trMagic != ISP_AVPK_MAGIC) {
                logE(TAG, "unpackToDir: bad trailer magic")
                return null
            }
            val footerEnd = size - TRAILER_SIZE
            if (footerLen > footerEnd || footerLen < FOOTER_HDR_SIZE) {
                logE(TAG, "unpackToDir: bad footer_len=$footerLen")
                return null
            }
            val footerStart = footerEnd - footerLen
            if (footerStart < 0L) {
                return null
            }

            raf.seek(footerStart)
            val fl = footerLen.toInt()
            val footerBytes = ByteArray(fl)
            raf.readFully(footerBytes, 0, fl)

            val fhBb = ByteBuffer.wrap(footerBytes, 0, FOOTER_HDR_SIZE).order(ByteOrder.LITTLE_ENDIAN)
            val fhMagic = fhBb.int
            val version = fhBb.short.toInt() and 0xffff
            val count = fhBb.short.toInt() and 0xffff
            logI(TAG, "unpackToDir fhMagic=$fhMagic ver=$version count=$count")
            if (fhMagic != ISP_AVPK_MAGIC || version != ISP_AVPK_VERSION_V1 || count <= 0) {
                logE(TAG, "unpackToDir: bad footer hdr magic=$fhMagic ver=$version count=$count")
                return null
            }
            val expectedFooter = FOOTER_HDR_SIZE + count * ENTRY_SIZE
            if (fl != expectedFooter) {
                logE(TAG, "unpackToDir: footer size mismatch exp=$expectedFooter act=$fl")
                return null
            }

            var h265: File? = null
            var aac: File? = null
            var parseOff = FOOTER_HDR_SIZE
            repeat(count) {
                if (parseOff + ENTRY_SIZE > footerBytes.size) {
                    logE(TAG, "unpackToDir: footer entry overflow")
                    return null
                }
                // 与磁盘一致:entry 内 offset 起始于 parseOff+4,共 8+8 字节小端
                val rawOffSize16 = footerBytes.copyOfRange(parseOff + 4, parseOff + 4 + 16)
                logI(
                    TAG,
                    "unpackToDir entry $it offset+size raw16(hex)=${hexBytes(rawOffSize16)} " +
                        "absFileOffset=${footerStart + parseOff + 4}..${footerStart + parseOff + 19} " +
                        "(LE uint64,uint64)"
                )
                val entryOff = readUInt64LE(footerBytes, parseOff + 4)
                val entrySize = readUInt64LE(footerBytes, parseOff + 12)
                val nameBytes = footerBytes.copyOfRange(parseOff + 20, parseOff + 20 + ISP_AVPK_NAME_MAX)
                val nameLen = nameBytes.indexOf(0).let { if (it < 0) nameBytes.size else it }
                val rawName = String(nameBytes, 0, nameLen, StandardCharsets.UTF_8).trim()
                val safeName = basenameSafe(rawName).ifEmpty { "unknown_$it.bin" }
                logI(TAG, "unpackToDir entry $it off=$entryOff size=$entrySize name=$rawName safe=$safeName")
                parseOff += ENTRY_SIZE

                if (entryOff + entrySize > footerStart) {
                    logE(TAG, "unpackToDir: entry range overflow $entryOff+$entrySize > $footerStart")
                    return null
                }

                val outFile = File(outDir, safeName)
                when {
                    safeName.endsWith(".h265", ignoreCase = true) -> {
                        if (h265 != null) {
                            logE(TAG, "unpackToDir: multiple .h265")
                            return null
                        }
                        h265 = outFile
                    }
                    safeName.endsWith(".aac", ignoreCase = true) -> {
                        if (aac != null) {
                            logE(TAG, "unpackToDir: multiple .aac")
                            return null
                        }
                        aac = outFile
                    }
                }

                raf.seek(entryOff)
                FileOutputStream(outFile).use { out ->
                    var remain = entrySize
                    val buf = ByteArray(256 * 1024)
                    while (remain > 0) {
                        val n = minOf(buf.size.toLong(), remain).toInt()
                        val read = raf.read(buf, 0, n)
                        if (read <= 0) {
                            logE(TAG, "unpackToDir: read error")
                            return null
                        }
                        out.write(buf, 0, read)
                        remain -= read.toLong()
                    }
                }
            }

            if (h265 == null || aac == null) {
                logE(TAG, "unpackToDir: missing h265 or aac h265=${h265 != null} aac=${aac != null}")
                h265?.delete()
                aac?.delete()
                return null
            }
            logI(TAG, "unpackToDir ok h265=${h265?.path} aac=${aac?.path}")
            return UnpackResult(h265!!, aac!!)
        }
    }

    private fun basenameSafe(name: String): String {
        val n = name.trim()
        if (n.isEmpty()) return ""
        val s = n.replace('\\', '/')
        return s.substringAfterLast('/')
    }

    private fun hexBytes(data: ByteArray): String {
        return data.joinToString(" ") { b -> "%02X".format(b.toInt() and 0xff) }
    }

    private fun readUInt64LE(buf: ByteArray, index: Int): Long {
        var v = 0L
        for (i in 0 until 8) {
            v = v or ((buf[index + i].toLong() and 0xFFL) shl (8 * i))
        }
        return v
    }
}

4.6 视频转码 MP4

复制代码
fun h265Aac2mp4(h265Path: String, aacPath: String, mp4Path: String): Int {
    val command = "-f hevc -i $h265Path -i $aacPath -c:v copy -c:a copy $mp4Path"
    val executeCode = FFmpeg.execute(command)
    return executeCode
}

fun h265ToMp4(inputH265: String, outputMp4: String): Int {
    val command = String.format("-f hevc -i %s -c:v copy -an %s", inputH265, outputMp4)
    val executeCode = FFmpeg.execute(command)
    return executeCode
}

完整转码逻辑如下:

复制代码
private fun muxerAvpkToMp4(avpkFile: File, timeMs: Long) {
    val unpacked = AvpkParser.unpackToDir(avpkFile, outDir) ?: return

    val mp4File = File(Constant.picturePath, GalleryMediaSaveUtil.generateFileName("mp4"))
    val hasValidAudio = unpacked.aacFile.exists() && unpacked.aacFile.length() > 0
    val mp4Code = if (hasValidAudio) {
        FFmpegManager.getInstance().h265Aac2mp4(
            unpacked.h265File.path, unpacked.aacFile.path, mp4File.path
        )
    } else {
        // 音频为空,只封装视频轨
        FFmpegManager.getInstance().h265ToMp4(unpacked.h265File.path, mp4File.path)
    }

    if (mp4Code == 0) {
        GalleryMediaSaveUtil.saveExistingVideo(mp4File, timeMs)
        LiveEventBus.get<String>(GALLERY_DATA_ADD).post(result.appPath)
    }
    // 清理临时文件
    unpacked.h265File.delete()
    unpacked.aacFile.delete()
}

4.7 存储路径

|-------------------|-----------------------|----------------------|
| 常量 | 路径 | 用途 |
| rawPath | .../gallery/raw | 原始文件(.avpk / .h264) |
| tempPath | .../gallery/temp | 转码中间产物(.h265 / .aac) |
| picturePath | .../gallery/picture | 最终 MP4 保存 |
| systemPicturePath | 系统相册目录 | MediaStore 注册 |

4.8 异常处理

|--------|---------------------|
| 异常场景 | 处理方式 |
| 文件拉取失败 | Toast 提示,跳过当前,继续下一个 |
| 列表拉取失败 | 结束同步,通知眼镜 |
| 分片传输异常 | 删除临时文件,跳过当前 |
| 存储空间不足 | 终止同步,提示用户 |
| 蓝牙断开 | 关闭 WiFi,终止同步 |

📂 小结

|------|-------------------------------------|
| 连接方案 | Phone GO(手机做 GO,眼镜做 GC) |
| 传输协议 | TCP Socket(EasyNet 封装) |
| 触发方式 | 手机查询 → 有更新 → 用户点击同步 |
| 拉取方式 | 串行逐文件拉取(非并发) |
| 图片处理 | 直接存入 sdcard |
| 视频处理 | .avpk 解包 → H265+AAC → FFmpeg 封装 MP4 |
| 编码格式 | H.265 + AAC |
| 落地位置 | 系统相册(MediaStore 注册) |

.avpk 轻量容器的思路是让眼镜端只做简单数据拼接,复杂封装交给手机端 FFmpeg,这是 BES 2800 平台资源受限下的合理工程取舍。

另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。


相关推荐
倔强的石头1068 小时前
远程运维环境搭建实战:节点小宝实现远程桌面 + 异地组网 + 文件挂载 + 内网穿透(附详细配置步骤)
内网穿透·p2p·远程桌面·远程运维·异地组网·wake-on-lan·工具教程
赋创小助手1 天前
多GPU服务器交付验收:GPU健康、P2P、NCCL与稳定性测试思路
运维·服务器·人工智能·ai·部署·gpu·p2p
海宇AI5 天前
零信任架构实战:基于海宇运营商近3个月平均账单构建自动化P2P信审网关
人工智能·架构·自动化·p2p
SendTomo7 天前
SendTomo 与 send.wang 全景对比评测与实战指南
网络·网络协议·tcp/ip·webrtc·p2p
MyBili8 天前
【安卓开发/搞机】快图浏览(QuickPic)技术解析:为何这款3MB应用仍是本地图库的性能天花板?
android·app·安卓·文件管理·相册·看图软件·手机相册
啊阿狸不会拉杆10 天前
《计算机网络-自顶向下方法》2.5 P2P 文件分发 读书笔记
计算机网络·wireshark·asp.net·p2p
FakeOccupational10 天前
【p2p、分布式,区块链笔记 IPFS】网关+多地址+HTTP RPC API+kubo-rpc-client
分布式·区块链·p2p
筝筝ba11 天前
extended/factory_reset.robot
linux·运维·服务器·网络·网络协议·p2p
SendTomo13 天前
send.wang:基于浏览器WebRTC实现无客户端文件互传
网络·python·网络协议·webrtc·p2p