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

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