// 参考文章 https://blog.0to1.cf/posts/cn-motion-photo-format
// 参考文章 https://blog.zzbd.org/motion-live-photo-webui
// 参考项目 https://github.com/flashlab/motion-live-photo
import fs from 'node:fs'
import path from 'node:path'
import { ffmpeg, logger } from 'node-karin'
import { Common } from './Common'
import { Config } from './Config'
const xmpHeaderBuffer = Buffer.from('http://ns.adobe.com/xap/1.0/\u0000', 'utf8')
const oppoExifHex =
'FFE100724578696600004D4D002A0000000800040100000400000001000005A001010004000000010000043C87690004000000010000003E011200030000000100000000000000000002928600020000000E0000005C920800040000000100000000000000006F706C75735F3833383836303800'
const xiaomiExifHex =
'FFE1007E4578696600004D4D002A0000000800040100000400000001000005A001010004000000010000043C01120003000000010000000087690004000000010000003E000000000003889700010000000101000000920800040000000100000000928600020000000E00000068000000006F706C75735F3833383836303800'
const huaweiHonorLiveIdFallback = 1915884
type MotionPhotoSystem = 'google' | 'xiaomi' | 'oppo' | 'huawei_honor'
const isJpegBuffer = (fileBuffer: Buffer): boolean => {
return fileBuffer.length > 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xd8
}
const getJpegDimensions = (jpegBuffer: Buffer): { width: number; height: number } | null => {
let offset = 2
while (offset + 9 < jpegBuffer.length) {
if (jpegBuffer[offset] !== 0xff) {
offset += 1
continue
}
const marker = jpegBuffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
if (offset + 3 >= jpegBuffer.length) return null
const segmentLength = jpegBuffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > jpegBuffer.length) return null
const isSofMarker =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
if (isSofMarker && segmentLength >= 7) {
const height = jpegBuffer.readUInt16BE(offset + 5)
const width = jpegBuffer.readUInt16BE(offset + 7)
if (width > 0 && height > 0) return { width, height }
return null
}
offset += 2 + segmentLength
}
return null
}
const hasExifApp1 = (jpegBuffer: Buffer): boolean => {
return jpegBuffer.includes(Buffer.from('Exif\u0000\u0000', 'binary'))
}
const buildExifSegment = (hex: string, width: number, height: number): Buffer => {
const exifBuffer = Buffer.from(hex, 'hex')
exifBuffer[28] = (width >> 24) & 0xff
exifBuffer[29] = (width >> 16) & 0xff
exifBuffer[30] = (width >> 8) & 0xff
exifBuffer[31] = width & 0xff
exifBuffer[40] = (height >> 24) & 0xff
exifBuffer[41] = (height >> 16) & 0xff
exifBuffer[42] = (height >> 8) & 0xff
exifBuffer[43] = height & 0xff
return exifBuffer
}
const getSystemExifHex = (system: MotionPhotoSystem): string | null => {
if (system === 'oppo') return oppoExifHex
if (system === 'xiaomi') return xiaomiExifHex
return null
}
const resolveMotionPhotoSystem = (): MotionPhotoSystem => {
const system = Config.app.livePhotoSystem
if (system === 'google' || system === 'xiaomi' || system === 'oppo' || system === 'huawei_honor') {
return system
}
return 'google'
}
const buildMotionPhotoXmp = (
videoLength: number,
presentationTimestampUs: number,
system: MotionPhotoSystem,
hdrGainMapLength?: number
): string => {
if (system === 'oppo') {
// OPPO 支持 HDR GainMap 的完整 XMP 数据
const containerItems = [
''
]
// 如果有 HDR GainMap 数据,添加 GainMap 项
if (hdrGainMapLength && hdrGainMapLength > 0) {
containerItems.push(
``
)
}
// 添加视频项
containerItems.push(
``
)
return (
'' +
'' +
'` +
'' +
containerItems.join('') +
'' +
'' +
'' +
''
)
}
if (system === 'xiaomi') {
return (
'' +
'' +
'' +
'' +
'' +
`` +
'' +
'' +
'' +
''
)
}
return (
'' +
'' +
'` +
'' +
'' +
`` +
'' +
'' +
'' +
''
)
}
const injectXmpToJpeg = (jpegBuffer: Buffer, xmpPacket: string, system: MotionPhotoSystem): Buffer => {
if (!isJpegBuffer(jpegBuffer)) {
throw new Error('输入图片不是 JPEG 格式')
}
const xmpPayload = Buffer.concat([xmpHeaderBuffer, Buffer.from(xmpPacket, 'utf8')])
const app1Length = xmpPayload.length + 2
if (app1Length > 65535) {
throw new Error('XMP 数据过大,无法写入 JPEG APP1')
}
const app1Segment = Buffer.alloc(4)
app1Segment[0] = 0xff
app1Segment[1] = 0xe1
app1Segment.writeUInt16BE(app1Length, 2)
const dimensions = getJpegDimensions(jpegBuffer)
const exifHex = getSystemExifHex(system)
const needExif = !hasExifApp1(jpegBuffer) && dimensions !== null && exifHex !== null
const exifSegment = needExif ? buildExifSegment(exifHex, dimensions.width, dimensions.height) : null
return Buffer.concat([jpegBuffer.subarray(0, 2), ...(exifSegment ? [exifSegment] : []), app1Segment, xmpPayload, jpegBuffer.subarray(2)])
}
const readOrConvertToJpeg = async (imagePath: string): Promise => {
const sourceBuffer = fs.readFileSync(imagePath)
if (isJpegBuffer(sourceBuffer)) {
return sourceBuffer
}
const tempJpegPath = path.join(Common.tempDri.images, `MotionPhoto_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`)
const result = await ffmpeg(`-y -i "${imagePath}" -frames:v 1 -q:v 2 "${tempJpegPath}"`)
if (!result.status) {
throw new Error(`图片转换 JPEG 失败: ${imagePath}`)
}
try {
return fs.readFileSync(tempJpegPath)
} finally {
fs.rmSync(tempJpegPath, { force: true })
}
}
/**
* 检测图片是否包含HDR GainMap数据
* @param imageBuffer JPEG图片缓冲区
* @returns HDR GainMap长度,如果没有则返回0
*/
const detectHdrGainMap = (imageBuffer: Buffer): number => {
try {
// 查找XMP数据段
const xmpStart = imageBuffer.indexOf('http://ns.adobe.com/xap/1.0/')
if (xmpStart === -1) return 0
// 查找HDR GainMap相关标记
const hdrGainMapMarker = 'Item:Semantic="GainMap"'
const gainMapIndex = imageBuffer.indexOf(hdrGainMapMarker, xmpStart)
if (gainMapIndex === -1) return 0
// 尝试提取GainMap长度
const lengthPattern = /Item:Length="(\d+)"/
const xmpSection = imageBuffer.subarray(xmpStart, xmpStart + 4096).toString('utf8')
const gainMapSection = xmpSection.substring(xmpSection.indexOf(hdrGainMapMarker))
const lengthMatch = gainMapSection.match(lengthPattern)
if (lengthMatch && lengthMatch[1]) {
const length = parseInt(lengthMatch[1], 10)
logger.debug(`检测到HDR GainMap,长度: ${length}`)
return length
}
// 如果找到GainMap标记但无法提取长度,返回默认值
logger.debug('检测到HDR GainMap标记,但无法提取长度,使用默认值')
return 463255 // 使用你提供的示例中的默认长度
} catch (error) {
logger.debug('HDR GainMap检测失败:', error)
return 0
}
}
export interface GoogleMotionPhotoOptions {
imagePath: string
videoPath: string
outputPath: string
presentationTimestampUs?: number
hdrGainMapLength?: number // HDR GainMap 长度,用于 OPPO HDR 支持
}
export const buildGoogleMotionPhoto = async (options: GoogleMotionPhotoOptions): Promise => {
const { imagePath, videoPath, outputPath, presentationTimestampUs, hdrGainMapLength } = options
try {
const system = resolveMotionPhotoSystem()
const imageBuffer = await readOrConvertToJpeg(imagePath)
const videoBuffer = fs.readFileSync(videoPath)
const resolvedPresentationTimestampUs =
presentationTimestampUs === undefined || presentationTimestampUs < 0 ? 0 : presentationTimestampUs
// 自动检测HDR GainMap(仅对OPPO系统)
const detectedHdrGainMapLength = system === 'oppo' ? (hdrGainMapLength ?? detectHdrGainMap(imageBuffer)) : 0
const huaweiHonorFooter = Buffer.from(
`v2_f35 409:1000 LIVE_${resolvedPresentationTimestampUs > 0 ? Math.floor(resolvedPresentationTimestampUs) : huaweiHonorLiveIdFallback}`,
'utf8'
)
const outputBuffer =
system === 'huawei_honor'
? Buffer.concat([imageBuffer, huaweiHonorFooter])
: (() => {
const xmpPacket = buildMotionPhotoXmp(videoBuffer.length, resolvedPresentationTimestampUs, system, detectedHdrGainMapLength)
const jpegWithXmp = injectXmpToJpeg(imageBuffer, xmpPacket, system)
return Buffer.concat([jpegWithXmp, videoBuffer])
})()
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, outputBuffer)
if (system === 'oppo' && detectedHdrGainMapLength > 0) {
logger.debug(`Motion Photo 封面生成成功(${system} with HDR): ${outputPath}`)
} else {
logger.debug(`Motion Photo 封面生成成功(${system}): ${outputPath}`)
}
return true
} catch (error) {
logger.error('Motion Photo 封面生成失败', error)
return false
}
}