第37篇|DualPhotoComposerService:把两张照片合成一张双镜照片
第 37 篇看图像合成服务。前后两路照片交付以后,项目希望生成一张更适合展示和分享的双镜作品:后摄大图作为主画面,前摄图以圆形小窗叠加。这个职责被放进 DualPhotoComposerService,页面层只负责传入 backPath、frontPath 和 compositePath。 本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目,
第37篇|DualPhotoComposerService:把两张照片合成一张双镜照片
第 37 篇看图像合成服务。前后两路照片交付以后,项目希望生成一张更适合展示和分享的双镜作品:后摄大图作为主画面,前摄图以圆形小窗叠加。这个职责被放进 DualPhotoComposerService,页面层只负责传入 backPath、frontPath 和 compositePath。
本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目,配图围绕运行页面和源码关键路径展开,读完以后可以直接回到工程里按函数名定位。
本篇目标
- 理解合成参数为什么要集中在服务层。
- 读懂 composeDualPhoto 的校验、解码、绘制和写出流程。
- 知道合成失败时为什么要保留原片。
- 为第 38 篇双拍闭环准备合成结果。
代码位置
entry/src/main/ets/services/DualPhotoComposerService.etsentry/src/main/ets/pages/Index.ets
一、合成服务让页面只关心结果
相册详情里用户看到的是一张双镜作品。页面不需要知道圆窗半径、描边、输出尺寸和像素拷贝方式,这些都属于图像处理服务。服务层越稳定,页面层越能专注交互、状态和错误提示。

图1 DualPhotoComposerService 接收两张照片并输出合成作品
二、版式参数:输出尺寸和前摄圆窗固定在服务里
服务顶部集中定义输出宽高、前摄圆窗比例、边距、描边和阴影。这样后续调整视觉风格时,只需要改服务参数,不必在页面拍摄流程里寻找魔法数字。

图2 DualPhotoComposerService 集中定义合成版式参数
export class DualPhotoComposerService {
private static readonly FALLBACK_OUTPUT_WIDTH: number = 1440;
private static readonly FALLBACK_OUTPUT_HEIGHT: number = 1920;
private static readonly DUAL_OUTPUT_WIDTH: number = 2160;
private static readonly DUAL_OUTPUT_HEIGHT: number = 3840;
private static readonly MAX_OUTPUT_LONG_SIDE: number = 2560;
private static readonly FRONT_SCALE: number = 0.22;
private static readonly FRONT_MARGIN_SCALE: number = 0.055;
private static readonly FRONT_BORDER_SCALE: number = 0.012;
private static readonly JPEG_QUALITY: number = 92;
这类参数属于图像生成规则,而不是 UI 状态。放错层会让拍照流程越来越难读。
三、composeDualPhoto:从校验路径到写出 composite 文件
composeDualPhoto 先检查路径是否存在,再读取两张图的尺寸,计算输出规格,解码 PixelMap,创建输出缓冲,然后把后摄作为底图、前摄裁成圆窗叠加。最后写出到 outputPath。

图3 composeDualPhoto 负责校验、解码、绘制和写出合成图
static async composeDualPhoto(backPath: string, frontPath: string, outputPath: string): Promise<void> {
if (backPath.trim().length === 0 || frontPath.trim().length === 0 || outputPath.trim().length === 0) {
throw new Error('双摄素材合成路径不完整。');
}
let backImage: DecodedBgraImage | undefined = undefined;
let frontImage: DecodedBgraImage | undefined = undefined;
let outputPixelMap: image.PixelMap | undefined = undefined;
let packer: image.ImagePacker | undefined = undefined;
try {
const backSourceSize = await DualPhotoComposerService.getImageSize(backPath);
const frontSourceSize = await DualPhotoComposerService.getImageSize(frontPath);
const outputSize = DualPhotoComposerService.normalizeOutputSize(
backSourceSize
);
const backSize = DualPhotoComposerService.normalizeCoverDecodeSize(
outputSize,
backSourceSize
);
const circleLayout = DualPhotoComposerService.createCircleOverlayLayout(outputSize);
const frontSize = DualPhotoComposerService.normalizeCircleDecodeSize(circleLayout.diameter, frontSourceSize);
backImage = await DualPhotoComposerService.decodeToBgra(backPath, backSize);
frontImage = await DualPhotoComposerService.decodeToBgra(frontPath, frontSize);
const outputBuffer = new ArrayBuffer(outputSize.width * outputSize.height * 4);
const outputBytes = new Uint8Array(outputBuffer);
DualPhotoComposerService.copyCoverImage(
outputBytes,
outputSize.width,
outputSize.height,
backImage.bytes,
backSize.width,
backSize.height
);
const shadowOffsetX = Math.max(8, Math.round(circleLayout.border * 1.7));
const shadowOffsetY = Math.max(12, Math.round(circleLayout.border * 2.3));
DualPhotoComposerService.fillCircle(
outputBytes,
outputSize.width,
outputSize.height,
circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetX,
circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetY,
Math.round(circleLayout.shellDiameter / 2),
0,
0,
0,
64
);
DualPhotoComposerService.fillCircle(
outputBytes,
outputSize.width,
outputSize.height,
circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2),
circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2),
Math.round(circleLayout.shellDiameter / 2),
246,
248,
这段流程里最值得学的是职责收口:页面层不操作像素,服务层不决定相册选中项。
四、文件读写:服务层独立处理本地文件
合成服务内部封装了读取和写入本地文件的方法。页面只传路径,不接触底层 byte array。这样合成失败可以被统一捕获,上层再决定保留原片或展示提示。

图4 DualPhotoComposerService 独立处理本地文件读取和写入
let file: fs.File | undefined = undefined;
try {
file = fs.openSync(path, fs.OpenMode.READ_ONLY);
const stat = fs.statSync(file.fd);
const buffer = new ArrayBuffer(stat.size);
fs.readSync(file.fd, buffer);
return buffer;
} catch (error) {
throw new Error(`读取素材失败:${DualPhotoComposerService.getErrorMessage(error)}`);
} finally {
DualPhotoComposerService.closeFileQuietly(file);
}
}
private static writeFile(path: string, buffer: ArrayBuffer): void {
let file: fs.File | undefined = undefined;
try {
file = fs.openSync(
path,
fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC
);
fs.writeSync(file.fd, buffer);
} catch (error) {
throw new Error(`写入合成素材失败:${DualPhotoComposerService.getErrorMessage(error)}`);
} finally {
DualPhotoComposerService.closeFileQuietly(file);
当合成失败时,不应该删除原始后摄和前摄照片。第 38 篇会看到上层如何在失败时返回原路径,保证用户作品不丢。
五、合成服务为什么先计算尺寸再解码
composeDualPhoto 的第一步不是直接读完整大图,而是先获取后摄、前摄原始尺寸,再计算输出图、后摄 cover 尺寸和前摄圆窗解码尺寸。这样做可以控制内存峰值,也让前摄小窗在不同分辨率照片上保持稳定比例。对于移动端相机项目来说,这比“先解码原图再缩放”更稳,因为原图可能来自不同镜头、不同方向和不同分辨率。
const backSourceSize = await DualPhotoComposerService.getImageSize(backPath);
const frontSourceSize = await DualPhotoComposerService.getImageSize(frontPath);
const outputSize = DualPhotoComposerService.normalizeOutputSize(
backSourceSize
);
const backSize = DualPhotoComposerService.normalizeCoverDecodeSize(
outputSize,
backSourceSize
);
const circleLayout = DualPhotoComposerService.createCircleOverlayLayout(outputSize);
const frontSize = DualPhotoComposerService.normalizeCircleDecodeSize(circleLayout.diameter, frontSourceSize);
backImage = await DualPhotoComposerService.decodeToBgra(backPath, backSize);
frontImage = await DualPhotoComposerService.decodeToBgra(frontPath, frontSize);
这段代码把“作品尺寸”“后摄铺底尺寸”“前摄头像尺寸”拆成三个独立问题。后摄负责覆盖整个画布,前摄只负责圆形区域,输出图负责被相册、分享和后续 AI 视频链路读取。页面层不参与这些计算,页面只关心最终文件路径是否生成成功。
六、圆形小窗不是装饰,而是作品信息结构
双镜照片的主语是后摄场景,前摄小窗是当时的人像或表情补充。服务层用阴影、外壳和圆形裁剪把两者区分开,避免前摄图直接压在后摄内容上造成混乱。代码中先复制后摄 cover 图,再绘制圆窗阴影和浅色外壳,最后把前摄像素按圆形 mask 写入输出缓冲区。
const shadowOffsetX = Math.max(8, Math.round(circleLayout.border * 1.7));
const shadowOffsetY = Math.max(12, Math.round(circleLayout.border * 2.3));
DualPhotoComposerService.fillCircle(
outputBytes,
outputSize.width,
outputSize.height,
circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetX,
circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetY,
Math.round(circleLayout.shellDiameter / 2),
0,
0,
0,
64
);
DualPhotoComposerService.fillCircle(
outputBytes,
outputSize.width,
outputSize.height,
circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2),
circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2),
Math.round(circleLayout.shellDiameter / 2),
246,
248,
250,
从验收角度看,圆窗位置、直径、边框和阴影都要在服务层集中控制。以后如果要扩展成方形小窗、双人小窗或带水印的成片,只需要调整合成服务的布局函数,而不是回到页面里重排一堆预览组件。
工程检查清单
- 图像合成参数集中管理,避免页面层散落魔法数字。
- 合成前检查输入路径,失败时抛出明确错误。
- PixelMap 使用后要释放,避免内存占用累积。
- 输出文件写入成功后再让上层替换成 compositePath。
- 合成失败不等于拍摄失败,原片要保留。
今日练习
- 调整前摄圆窗比例的参数,预估相册展示会发生什么变化。
- 阅读
composeDualPhoto中的资源释放逻辑,确认异常路径是否也能清理。 - 思考如果以后要加入水印,应该放在合成服务还是页面层。
下一篇会继续沿着同一条工程链路往下拆:先看用户能看到的效果,再回到源码确认状态、文件和服务边界是否闭合。
更多推荐



所有评论(0)