Android 自定义实况图(Live Photo)拍摄方案

在 Android 端,为了实现与 iOS 相同的逻辑——“自主掌控 GPU 特效(如美颜 Shader、AI 挂件),且点击拍摄自动捕捉前后各 1.5 秒(共 3 秒)的视频”,我们面临着和 iOS 不同的硬件生态。

Android 系统相册(如 Google Photos、三星/小米相册)识别实况图(Motion Photo)的底层物理本质是:单体复合文件。即前半部分是标准的 JPEG 静态图片,后半部分是无缝追加(Append)的 MP4 视频。同时,在 JPEG 的 Exif 中通过 XMP 元数据声明动态视频的字节长度(Length)与偏移量(Offset)。

为了不爆内存,Android 端同样需要建立一个 GPU 级别的环形缓冲区(Ring Buffer)。以下是基于 Java + OpenGL ES + MediaCodec 的完整落地实现方案。

1、系统架构设计与数据分流

为了在按下快门的瞬间拿到前后各 1.5 秒的帧,Android 管线采用 EGL 双缓冲区纹理复用 机制:

                    [Camera2 / CameraX 采集源]
                                │
                                ▼ (输出 OES 外部纹理)
                    [GPU 特效引擎 (OpenGL Shader)] 
                                │ (原地渲染美颜、特效)
                                ▼
                    ┌──────────────────┐
                    │  纹理分流器 (Splitter) │
                    └────────┬─────────┘
                             │
              ┌──────────────┴──────────────┐
              ▼                             ▼
   [路径 A: 持续预录制分支]        [路径 B: 静态图快门分支]
              │                             │
    ┌─────────────────┐           (点击快门触发)
    │ OpenGL 环形缓冲 │                     │
    │  (Ring Buffer)  │                     ▼
    │ (暂存前 1.5s 纹理)│           捕获当前高分辨率 FBO 纹理
    └────────┬────────┘                     │
             │                              ▼
      (点击快门激活)                编码为包含标准谷歌 XMP
             │                     元数据 (MotionPhoto=1)
             ▼                     的超清 JPEG 主文件
    导出历史 1.5s 帧 + 
    继续追加未来 1.5s 帧
             │
             ▼
    [MediaCodec 硬件编码] ──► 编码为 H.264 MP4 临时文件
                                     │
                                     ▼
                            [二进制追加器 (Append)]
                                     │
                                     ▼
                      [最终合成的单体 .jpg 实况图文件]

2、 核心组件一:基于 OpenGL ES 的纹理环形缓冲区

为了实现全显存零拷贝,我们不能在内存中缓存大块的 byte[] YUV 数据,而是利用 OpenGL 的 Frame Buffer Object (FBO) 和 Texture (纹理) 建立一个固定容量、循环复用的常驻显存队列。

2.1、环形节点与缓冲区实现 (LivePhotoRingBuffer.java)

import android.opengl.GLES20;
import java.util.ArrayList;
import java.util.List;

publicclass LivePhotoRingBuffer {
    
    // 单帧内部包装
    publicstaticclass LiveFrame {
        publicint textureId = -1;
        publicint fboId = -1;
        publiclong timestampNs = 0;
    }

    privatefinal List<LiveFrame> mBuffer;
    privatefinalint mCapacity;
    privateint mHead = 0;
    privateint mTail = 0;
    privateint mCount = 0;
    privatefinal Object mLock = new Object();

    public LivePhotoRingBuffer(int capacity, int width, int height) {
        mCapacity = capacity;
        mBuffer = new ArrayList<>(capacity);

        // 预先开辟固定数量的 FBO 和纹理,常驻显存,绝对防止运行时动态 OOM
        for (int i = 0; i < capacity; i++) {
            LiveFrame frame = new LiveFrame();
            
            int[] textures = newint[1];
            GLES20.glGenTextures(1, textures, 0);
            frame.textureId = textures[0];
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, frame.textureId);
            GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

            int[] fbos = newint[1];
            GLES20.glGenFramebuffers(1, fbos, 0);
            frame.fboId = fbos[0];
            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frame.fboId);
            GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, frame.textureId, 0);

            mBuffer.add(frame);
        }
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    }

    /**
     * 日常预览时,持续把已经渲染好特效的当前 Texture,通过 Blit 或绘制 COPY 到环形缓冲区内
     */

    public void pushFrame(int inputTextureId, TextureRenderProgram renderer, long timestampNs) {
        synchronized (mLock) {
            LiveFrame frame = mBuffer.get(mTail);
            frame.timestampNs = timestampNs;

            // 原地切换到环形节点的 FBO,运行 Shader 将带特效的画面画进当前节点纹理中
            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frame.fboId);
            GLES20.glViewport(0, 0, renderer.getWidth(), renderer.getHeight());
            renderer.draw(inputTextureId); 

            mTail = (mTail + 1) % mCapacity;

            if (mCount < mCapacity) {
                mCount++;
            } else {
                // 队列满了,头指针向前滚动,覆写最老的一帧
                mHead = (mHead + 1) % mCapacity;
            }
        }
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    }

    /**
     * 点击快门瞬间,一键导出过去 1.5 秒内积攒的所有历史纹理节点
     */

    public List<LiveFrame> exportHistoryFrames() {
        synchronized (mLock) {
            List<LiveFrame> history = new ArrayList<>();
            int current = mHead;
            for (int i = 0; i < mCount; i++) {
                history.add(mBuffer.get(current));
                current = (current + 1) % mCapacity;
            }
            return history;
        }
    }

    public void clear() {
        synchronized (mLock) {
            mHead = 0;
            mTail = 0;
            mCount = 0;
        }
    }
}

3、 核心组件二:前后 1.5 秒状态机调度与 MediaCodec 编码引擎

Android 需要建立一个独立运行的 EGL 后台硬编线程,用来把导出的纹理排队投喂给 MediaCodec

3.1、核心实况拍摄调度器实现 (LivePhotoCaptureEngine.java)

import android.graphics.Rect;
import android.media.MediaCodec;
import android.media.MediaFormat;
import android.media.MediaMuxer;
import android.opengl.EGL14;
import android.opengl.EGLExt;
import android.os.Handler;
import android.os.HandlerThread;
import java.io.File;
import java.util.ArrayList;
import java.util.List;

publicclass LivePhotoCaptureEngine {
    private LivePhotoRingBuffer mRingBuffer;
    private TextureRenderProgram mRenderer; // 自定义 Shader 渲染器
    privateboolean mIsCaptureTriggered = false;
    privatelong mShutterTimestampNs = 0;
    
    // 异步硬编管线
    private HandlerThread mEncoderThread;
    private Handler mEncoderHandler;
    private MediaCodec mMediaCodec;
    private MediaMuxer mMediaMuxer;
    privateint mVideoTrackIndex = -1;
    private Surface mCodecInputSurface;
    
    privatefinal List<LivePhotoRingBuffer.LiveFrame> mFramesToEncode = new ArrayList<>();
    private File mTmpVideoFile;
    private File mFinalJpegFile;

    public void init(int width, int height, TextureRenderProgram renderer) {
        mRenderer = renderer;
        // 30fps下,1.5秒对应 45 帧容量
        mRingBuffer = new LivePhotoRingBuffer(45, width, height);
        
        mEncoderThread = new HandlerThread("LivePhotoEncoderPipeline");
        mEncoderThread.start();
        mEncoderHandler = new Handler(mEncoderThread.getLooper());
    }

    /**
     * 相机数据回调触点 (运行在相机预览的 GL 线程)
     */

    public void onFrameAvailable(int cameraOesTextureId, long timestampNs) {
        // 1. 渲染公共特效:在此处运行全图的美颜、特效 Shader
        int effectTextureId = mRenderer.processEffects(cameraOesTextureId);

        if (!mIsCaptureTriggered) {
            // 状态一:日常状态,源源不断将带特效的纹理送入 Ring Buffer 压舱
            mRingBuffer.pushFrame(effectTextureId, mRenderer, timestampNs);
        } else {
            // 状态二:快门已被触发,持续收集未来 1.5 秒的帧进入长序列
            LivePhotoRingBuffer.LiveFrame futureFrame = new LivePhotoRingBuffer.LiveFrame();
            // 实际工程中需要深度拷贝当前纹理至临时缓冲区,此处简化逻辑
            // saveTextureToFrame(effectTextureId, futureFrame, timestampNs);
            mFramesToEncode.add(futureFrame);

            // 当时间差超过 1.5 秒(1,500,000,000 纳秒),执行收尾和合体封装
            if (timestampNs - mShutterTimestampNs >= 1500000000L) {
                mIsCaptureTriggered = false;
                mEncoderHandler.post(this::finalizeLiveVideoPipeline);
            }
        }
    }

    /**
     * 外部业务层调用:当用户点击拍摄按钮
     */

    public void triggerCapture(File finalJpegFile, File tmpVideoFile) {
        if (mIsCaptureTriggered) return;
        mIsCaptureTriggered = true;
        mFinalJpegFile = finalJpegFile;
        mTmpVideoFile = tmpVideoFile;

        // 1. 瞬间截获 Ring Buffer 过去 1.5 秒的历史帧
        List<LivePhotoRingBuffer.LiveFrame> history = mRingBuffer.exportHistoryFrames();
        mFramesToEncode.addAll(history);

        if (!history.isEmpty()) {
            mShutterTimestampNs = history.get(history.size() - 1).timestampNs;
            // 💡 异步触发静态图分支:直接将快门瞬间这一帧的 FBO 纹理转换为 JPEG 保存
            saveStillJpeg(history.get(history.size() - 1).textureId, mFinalJpegFile);
        }

        // 2. 异步点火 MediaCodec 编码引擎
        mEncoderHandler.post(() -> initMediaCodec(mTmpVideoFile));
    }

    private void initMediaCodec(File outputFile) {
        try {
            MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, mRenderer.getWidth(), mRenderer.getHeight());
            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
            format.setInteger(MediaFormat.KEY_BIT_RATE, 4000000); // 4Mbps
            format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);

            mMediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
            mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            
            // 拿到用于硬编零拷贝的 Surface,挂载到后台的 EGL 环境上
            mCodecInputSurface = mMediaCodec.createInputSurface();
            mMediaCodec.start();

            mMediaMuxer = new MediaMuxer(outputFile.getAbsolutePath(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
            mVideoTrackIndex = -1;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3.2、视频硬编落盘与收尾逻辑

当后 1.5 秒收集完毕,后台线程在独立的 EGL 上下文里快速遍历排序后的 90 帧,依次利用 GPU 渲染绘制到 MediaCodec 的 InputSurface 中,完成高性能硬编。

private void finalizeLiveVideoPipeline() {
    // 1. 初始化独立的硬件编码 EGL 环境并与 mCodecInputSurface 绑定
    // mEncoderEglCore.makeCurrent(mCodecInputSurface);

    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();

    // 2. 顺序抽取 3 秒内所有的纹理帧进行高能串行硬编
    for (LivePhotoRingBuffer.LiveFrame frame : mFramesToEncode) {
        GLES20.glViewport(0, 0, mRenderer.getWidth(), mRenderer.getHeight());
        mRenderer.draw(frame.textureId); // 将环形缓冲区内的特征纹理直接画上 Surface

        // 绑定该帧对应的原厂绝对纳秒时间戳
        // EGLExt.eglPresentationTimeANDROID(mEglDisplay, mEglSurface, frame.timestampNs);
        // EGL14.eglSwapBuffers(mEglDisplay, mEglSurface);

        // 3. 驱动 MediaCodec 排水写片
        int outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, 0);
        while (outputBufferIndex >= 0) {
            ByteBuffer encodedData = mMediaCodec.getOutputBuffer(outputBufferIndex);
            if (bufferInfo.size != 0) {
                if (mVideoTrackIndex == -1) {
                    mVideoTrackIndex = mMediaMuxer.addTrack(mMediaCodec.getOutputFormat());
                    mMediaMuxer.start();
                }
                encodedData.position(bufferInfo.offset);
                encodedData.limit(bufferInfo.offset + bufferInfo.size);
                mMediaMuxer.writeSampleData(mVideoTrackIndex, encodedData, bufferInfo);
            }
            mMediaCodec.releaseOutputBuffer(outputBufferIndex, false);
            outputBufferIndex = mMediaCodec.dequeueOutputBuffer(bufferInfo, 0);
        }
    }

    // 4. 关闭编码管线
    mMediaCodec.stop();
    mMediaCodec.release();
    mMediaMuxer.stop();
    mMediaMuxer.release();

    // 5. 🛠 终极核心:步入两路文件的二进制合体阶段
    File finalLivePhotoFile = new File(mFinalJpegFile.getParent(), "LIVE_" + mFinalJpegFile.getName());
    mergeToAndroidMotionPhoto(mFinalJpegFile, mTmpVideoFile, finalLivePhotoFile);

    // 6. 重置状态机
    mFramesToEncode.clear();
    mRingBuffer.clear();
}

4、 核心组件三:包含谷歌标准 XMP 元数据的单体复合封装

Android 相册能够识别实况图的底层关键,就是往 JPEG 的 Exif 头部中注入 XMP 元数据(声明 MotionPhoto=1,并指明追随在屁股后面的视频文件体积),然后物理追加。

import androidx.exifinterface.media.ExifInterface;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;

private void mergeToAndroidMotionPhoto(File jpegFile, File mp4File, File outputFile) {
    try {
        long mp4Length = mp4File.length();

        // 1. 将原始 JPEG 纯内容复制到最终目标文件
        try (FileInputStream imageInput = new FileInputStream(jpegFile);
             FileOutputStream finalOutput = new FileOutputStream(outputFile)) {
            byte[] buf = newbyte[4096];
            int bytesRead;
            while ((bytesRead = imageInput.read(buf)) > 0) {
                finalOutput.write(buf, 0, bytesRead);
            }
        }

        // 2. 写入符合 Google 官方标准的开放式 XMP 元数据暗号
        ExifInterface exif = new ExifInterface(outputFile.getAbsolutePath());
        
        String xmpMetadata = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Adobe XMP Core 5.1.0-jc003\">\n" +
                "  <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n" +
                "    <rdf:Description rdf:about=\"\"\n" +
                "        xmlns:Container=\"http://ns.google.com/photos/1.0/container/\"\n" +
                "        xmlns:Item=\"http://ns.google.com/photos/1.0/container/item/\">\n" +
                "      <Container:Directory>\n" +
                "        <rdf:Seq>\n" +
                "          <rdf:li rdf:parseType=\"Resource\">\n" +
                "            <Item:Mime>image/jpeg</Item:Mime>\n" +
                "            <Item:Semantic>Primary</Item:Semantic>\n" +
                "            <Item:Length>0</Item:Length>\n" +
                "            <Item:Padding>0</Item:Padding>\n" +
                "          </rdf:li>\n" +
                "          <rdf:li rdf:parseType=\"Resource\">\n" +
                "            <Item:Mime>video/mp4</Item:Mime>\n" +
                "            <Item:Semantic>MotionPhoto</Item:Semantic>\n" +
                "            <Item:Length>" + mp4Length + "</Item:Length>\n" +
                "            <Item:Padding>0</Item:Padding>\n" +
                "          </rdf:li>\n" +
                "        </rdf:Seq>\n" +
                "      </Container:Directory>\n" +
                "    </rdf:Description>\n" +
                "  </rdf:RDF>\n" +
                "</x:xmpmeta>";

        exif.setAttribute(ExifInterface.TAG_XMP, xmpMetadata);
        exif.setAttribute("MotionPhoto", "1"); // 强行唤醒安卓原生相册的实况播放按钮
        exif.saveAttributes();

        // 3. 物理级追加:将整个 H.264 MP4 视频的二进制流直接挂载在 JPEG 文件的尾部
        try (FileInputStream videoInput = new FileInputStream(mp4File);
             FileOutputStream finalAppendOutput = new FileOutputStream(outputFile, true)) {
            byte[] buf = newbyte[4096];
            int bytesRead;
            while ((bytesRead = videoInput.read(buf)) > 0) {
                finalAppendOutput.write(buf, 0, bytesRead);
            }
        }

        // 4. 销毁垃圾中间临时文件
        jpegFile.delete();
        mp4File.delete();
        System.out.println("Android 自定义实况图单体封装彻底闭环!");

    } catch (Exception e) {
        e.printStackTrace();
    }
}

5、 Android 端生产级架构避坑指南

  1. 绝对规避 glReadPixels(全链路硬件直通):在保存静态图或将 Ring Buffer 投喂给 MediaCodec 时,绝对不准出现 glReadPixels(从显存回传内存)的动作。否则,在 1080P/4K 像素下,CPU 单帧搬运耗时会飙升至 ,引发预览剧烈掉帧。静态图保存应通过 ImageReader 的 HardwareBuffer 零拷贝通道,或直接用硬件 JPEG 编码器进行 FBO 纹理离屏打桩。
  2. EGL 上下文跨线程常驻(Context Sharing):相机采集线程(主 GL 线程)和编码线程(LivePhotoEncoderPipeline)是跨线程的。为了让编码线程能顺畅读取 LivePhotoRingBuffer 里的纹理 ID,初始化编码器的 EGLContext 时,必须将主 GL 线程的 EGLContext 作为参数传进去,实现共享纹理资源。 否则编码线程会读出一片死黑。
  3. 精准处理首帧 GOP 对齐:MediaMuxer 写入短视频时,第一帧必须确保是关键帧(I-Frame)。在 finalize 阶段遍历 90 帧送入 MediaCodec 时,由于我们重新设置了第一个帧的 PTS 相对时间为 0,必须通过 Bundle 向 MediaCodec 强行发送一个 MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME 指令,命令编码器必须在第一帧起笔位置强制开辟 I 帧,否则在系统相册里点开实况图时,前 1.5 秒会出现恶心的绿屏或灰阶马赛克。

学习和提升音视频开发技术,欢迎你加入我们的知识星球

Android 自定义实况图(Live Photo)拍摄方案

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐