feat: support hdr motion photo (#155)

This commit is contained in:
Wenzhuo Liu
2025-11-14 14:26:03 +08:00
committed by GitHub
parent 6085335242
commit f2658cf5a5
6 changed files with 128 additions and 196 deletions

View File

@@ -48,22 +48,11 @@ export const LivePhotoVideo = ({
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const videoAnimateController = useAnimationControls() const videoAnimateController = useAnimationControls()
const presentationTimestampRef = useRef<number | undefined>(undefined)
useEffect(() => { useEffect(() => {
onPlayingChange?.(isPlayingLivePhoto) onPlayingChange?.(isPlayingLivePhoto)
}, [isPlayingLivePhoto, onPlayingChange]) }, [isPlayingLivePhoto, onPlayingChange])
// Extract and track presentationTimestamp for Motion Photo
useEffect(() => {
if (videoSource.type === 'motion-photo' && videoSource.presentationTimestamp) {
// Convert microseconds to seconds
presentationTimestampRef.current = videoSource.presentationTimestamp / 1_000_000
} else {
presentationTimestampRef.current = undefined
}
}, [videoSource])
useEffect(() => { useEffect(() => {
if (!isCurrentImage || livePhotoVideoLoaded || isConvertingVideo || !videoRef.current) { if (!isCurrentImage || livePhotoVideoLoaded || isConvertingVideo || !videoRef.current) {
return return
@@ -158,22 +147,6 @@ export const LivePhotoVideo = ({
stop() stop()
}, [stop]) }, [stop])
// Handle Motion Photo presentation timestamp
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current
const timestamp = presentationTimestampRef.current
// Only handle Motion Photo with valid timestamp
if (!video || timestamp === undefined || videoSource.type !== 'motion-photo') {
return
}
// Stop playback when reaching or passing the presentation timestamp
if (video.currentTime >= timestamp) {
stop()
}
}, [videoSource, stop])
return ( return (
<m.video <m.video
ref={videoRef} ref={videoRef}
@@ -184,7 +157,6 @@ export const LivePhotoVideo = ({
}} }}
muted muted
playsInline playsInline
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded} onEnded={handleVideoEnded}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={videoAnimateController} animate={videoAnimateController}

View File

@@ -124,6 +124,16 @@ const pickKeys: Array<keyof Tags | (string & {})> = [
'GPSLongitudeRef', 'GPSLongitudeRef',
// HDR相关字段 // HDR相关字段
'MPImageType', 'MPImageType',
'UniformResourceName',
// Motion Photo 相关字段
'MotionPhoto',
'MotionPhotoVersion',
'MotionPhotoPresentationTimestampUs',
'ContainerDirectory',
'MicroVideo',
'MicroVideoVersion',
'MicroVideoOffset',
'MicroVideoPresentationTimestampUs',
] ]
function handleExifData(exifData: Tags, metadata: Metadata): PickedExif { function handleExifData(exifData: Tags, metadata: Metadata): PickedExif {
const date = { const date = {

View File

@@ -0,0 +1,36 @@
import type { ConsolaInstance } from 'consola'
import type { ContainerDirectoryItem } from 'exiftool-vendored'
interface GainMapDetectParams {
exifData?: Record<string, unknown> | null
logger?: ConsolaInstance
}
/**
* Detects HDR GainMap from ContainerDirectory metadata.
* Works for both static Ultra HDR images and Motion Photo + HDR combinations.
*/
export const detectGainMap = ({ exifData, logger }: GainMapDetectParams): boolean => {
try {
const containerDirectory = exifData?.ContainerDirectory as ContainerDirectoryItem[] | undefined
if (!containerDirectory || !Array.isArray(containerDirectory)) {
return false
}
// Find GainMap item
for (const entry of containerDirectory) {
const item = entry.Item
if (!item) continue
if (item.Semantic === 'GainMap' && item.Length) {
logger?.info('[gainmap] Found HDR GainMap in ContainerDirectory')
return true
}
}
return false
} catch (error) {
logger?.error('[gainmap] Unexpected error while detecting', error)
return false
}
}

View File

@@ -18,6 +18,7 @@ import type { S3ObjectLike } from '../types/s3.js'
import { shouldProcessPhoto } from './cache-manager.js' import { shouldProcessPhoto } from './cache-manager.js'
import { processExifData, processThumbnailAndBlurhash, processToneAnalysis } from './data-processors.js' import { processExifData, processThumbnailAndBlurhash, processToneAnalysis } from './data-processors.js'
import { getPhotoExecutionContext } from './execution-context.js' import { getPhotoExecutionContext } from './execution-context.js'
import { detectGainMap } from './gainmap-detector.js'
import { extractPhotoInfo } from './info-extractor.js' import { extractPhotoInfo } from './info-extractor.js'
import { processLivePhoto } from './live-photo-handler.js' import { processLivePhoto } from './live-photo-handler.js'
import { getGlobalLoggers } from './logger-adapter.js' import { getGlobalLoggers } from './logger-adapter.js'
@@ -178,13 +179,18 @@ export async function executePhotoProcessingPipeline(
// 4. 处理 EXIF 数据 // 4. 处理 EXIF 数据
const exifData = await processExifData(imageBuffer, imageData.rawBuffer, photoKey, existingItem, options) const exifData = await processExifData(imageBuffer, imageData.rawBuffer, photoKey, existingItem, options)
// 5. 检测 Motion Photo从图片中提取嵌入视频的元数据 // 5. 检测 HDR GainMapUltra HDR 图片
const hasGainMap = detectGainMap({
exifData: exifData as Record<string, unknown> | null,
})
// 6. 检测 Motion Photo从图片中提取嵌入视频的元数据
const motionPhotoMetadata = detectMotionPhoto({ const motionPhotoMetadata = detectMotionPhoto({
rawImageBuffer: imageData.rawBuffer, rawImageBuffer: imageData.rawBuffer,
exifData: exifData as Record<string, unknown> | null, exifData: exifData as Record<string, unknown> | null,
}) })
// 6. 处理 Live Photo独立的视频文件 // 7. 处理 Live Photo独立的视频文件
const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap, storageManager) const livePhotoResult = await processLivePhoto(photoKey, livePhotoMap, storageManager)
// 检测冲突:不允许同时存在 Motion Photo 和 Live Photo // 检测冲突:不允许同时存在 Motion Photo 和 Live Photo
@@ -202,7 +208,6 @@ export async function executePhotoProcessingPipeline(
// 9. 构建照片清单项 // 9. 构建照片清单项
const aspectRatio = metadata.width / metadata.height const aspectRatio = metadata.width / metadata.height
const photoItem: PhotoManifestItem = { const photoItem: PhotoManifestItem = {
id: photoId, id: photoId,
title: photoInfo.title, title: photoInfo.title,
@@ -224,20 +229,23 @@ export async function executePhotoProcessingPipeline(
video: video:
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
? { ? {
type: 'motion-photo', type: 'motion-photo',
offset: motionPhotoMetadata.motionPhotoOffset, offset: motionPhotoMetadata.motionPhotoOffset,
size: motionPhotoMetadata.motionPhotoVideoSize, size: motionPhotoMetadata.motionPhotoVideoSize,
presentationTimestamp: motionPhotoMetadata.presentationTimestampUs, presentationTimestamp: motionPhotoMetadata.presentationTimestampUs,
} }
: livePhotoResult.isLivePhoto : livePhotoResult.isLivePhoto
? { ? {
type: 'live-photo', type: 'live-photo',
videoUrl: livePhotoResult.livePhotoVideoUrl!, videoUrl: livePhotoResult.livePhotoVideoUrl!,
s3Key: livePhotoResult.livePhotoVideoS3Key!, s3Key: livePhotoResult.livePhotoVideoS3Key!,
} }
: undefined, : undefined,
// HDR 相关字段 // HDR 相关字段
isHDR: exifData?.MPImageType === 'Gain Map Image', isHDR:
exifData?.MPImageType === 'Gain Map Image' ||
exifData?.UniformResourceName === 'urn:iso:std:iso:ts:21496:-1' ||
hasGainMap,
} }
loggers.image.success(`✅ 处理完成:${photoKey}`) loggers.image.success(`✅ 处理完成:${photoKey}`)

View File

@@ -1,4 +1,5 @@
import type { ConsolaInstance } from 'consola' import type { ConsolaInstance } from 'consola'
import type { ContainerDirectoryItem } from 'exiftool-vendored'
export interface MotionPhotoMetadata { export interface MotionPhotoMetadata {
isMotionPhoto: boolean isMotionPhoto: boolean
@@ -13,7 +14,6 @@ interface MotionPhotoDetectParams {
logger?: ConsolaInstance logger?: ConsolaInstance
} }
const MAX_XMP_SCAN_BYTES = 512 * 1024 // 512KB should cover standard XMP blocks
const MIN_VIDEO_SIZE_BYTES = 8 * 1024 // 8KB minimal sanity check const MIN_VIDEO_SIZE_BYTES = 8 * 1024 // 8KB minimal sanity check
const MP4_FTYP = Buffer.from('ftyp') const MP4_FTYP = Buffer.from('ftyp')
@@ -40,64 +40,6 @@ const toNumber = (value: unknown): number | null => {
return null return null
} }
const extractXmpSegment = (buffer: Buffer): string | null => {
const scanSize = Math.min(buffer.length, MAX_XMP_SCAN_BYTES)
if (scanSize === 0) {
return null
}
const header = buffer.toString('utf8', 0, scanSize)
const startIndex = header.indexOf('<x:xmpmeta')
if (startIndex === -1) {
return null
}
const endIndex = header.indexOf('</x:xmpmeta>')
if (endIndex === -1) {
return null
}
return header.slice(startIndex, endIndex + '</x:xmpmeta>'.length)
}
const extractXmpBoolean = (xmp: string, tagName: string): boolean | null => {
const regex = new RegExp(`<[^:>]*:${tagName}>([^<]+)</[^>]+>`, 'i')
const match = xmp.match(regex)
if (!match) return null
return toBoolean(match[1])
}
const extractXmpNumber = (xmp: string, tagName: string): number | null => {
const regex = new RegExp(`<[^:>]*:${tagName}>([^<]+)</[^>]+>`, 'i')
const match = xmp.match(regex)
if (!match) return null
return toNumber(match[1])
}
const escapeRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
const buildAttrPattern = (attrName: string) => {
const escaped = escapeRegExp(attrName)
if (attrName.includes(':')) {
return escaped
}
return `(?:[\\w-]+:)?${escaped}`
}
const extractXmpAttributeBoolean = (xmp: string, attrName: string): boolean | null => {
const regex = new RegExp(`${buildAttrPattern(attrName)}="([^"]+)"`, 'i')
const match = xmp.match(regex)
if (!match) return null
return toBoolean(match[1])
}
const extractXmpAttributeNumber = (xmp: string, attrName: string): number | null => {
const regex = new RegExp(`${buildAttrPattern(attrName)}="([^"]+)"`, 'i')
const match = xmp.match(regex)
if (!match) return null
return toNumber(match[1])
}
const validateMp4Buffer = (buffer: Buffer): boolean => { const validateMp4Buffer = (buffer: Buffer): boolean => {
if (buffer.length < MIN_VIDEO_SIZE_BYTES) { if (buffer.length < MIN_VIDEO_SIZE_BYTES) {
return false return false
@@ -109,8 +51,8 @@ const validateMp4Buffer = (buffer: Buffer): boolean => {
} }
/** /**
* Detects Motion Photo metadata from image buffer without extracting the video file. * Detects Motion Photo metadata using Android Motion Photo format 1.0 specification.
* Returns offset and size information for frontend to extract video on-demand. * Supports both standard ContainerDirectory format and legacy MicroVideo format.
*/ */
export const detectMotionPhoto = ({ export const detectMotionPhoto = ({
rawImageBuffer, rawImageBuffer,
@@ -120,126 +62,79 @@ export const detectMotionPhoto = ({
try { try {
const rawLength = rawImageBuffer.length const rawLength = rawImageBuffer.length
const exifIndicatesMotion = toBoolean(exifData?.MotionPhoto) || toBoolean(exifData?.MicroVideo) // Check Motion Photo flags (standard and legacy)
let detectedMotion = exifIndicatesMotion const isMotionPhotoFlag = toBoolean(exifData?.MotionPhoto) || toBoolean(exifData?.MicroVideo)
let presentationTimestampUs = toNumber( const presentationTimestampUs = toNumber(
exifData?.MotionPhotoPresentationTimestampUs ?? exifData?.MicroVideoPresentationTimestampUs, exifData?.MotionPhotoPresentationTimestampUs ?? exifData?.MicroVideoPresentationTimestampUs,
) )
const offsetCandidates = new Set<number>() let videoOffset: number | null = null
const addOffsetCandidate = (value: number | null | undefined) => {
if (value === null || value === undefined) return
if (!Number.isFinite(value)) return
const numeric = Number(value)
if (numeric <= 0) return
offsetCandidates.add(numeric)
}
addOffsetCandidate(toNumber(exifData?.MicroVideoOffset))
const xmpSegment = extractXmpSegment(rawImageBuffer)
if (xmpSegment) {
if (!detectedMotion) {
const motionFlags = [
extractXmpBoolean(xmpSegment, 'MotionPhoto'),
extractXmpBoolean(xmpSegment, 'GCamera:MotionPhoto'),
extractXmpBoolean(xmpSegment, 'MicroVideo'),
extractXmpBoolean(xmpSegment, 'GCamera:MicroVideo'),
extractXmpAttributeBoolean(xmpSegment, 'MotionPhoto'),
extractXmpAttributeBoolean(xmpSegment, 'GCamera:MotionPhoto'),
extractXmpAttributeBoolean(xmpSegment, 'MicroVideo'),
extractXmpAttributeBoolean(xmpSegment, 'GCamera:MicroVideo'),
].filter((flag) => flag !== null) as boolean[]
if (motionFlags.some(Boolean)) {
detectedMotion = true
logger?.info('[motion-photo] XMP detected MotionPhoto flags')
}
}
;[
extractXmpNumber(xmpSegment, 'MicroVideoOffset'),
extractXmpNumber(xmpSegment, 'GCamera:MicroVideoOffset'),
extractXmpAttributeNumber(xmpSegment, 'MicroVideoOffset'),
extractXmpAttributeNumber(xmpSegment, 'GCamera:MicroVideoOffset'),
].forEach((candidate) => addOffsetCandidate(candidate))
if (presentationTimestampUs === null) {
presentationTimestampUs =
extractXmpNumber(xmpSegment, 'MotionPhotoPresentationTimestampUs') ??
extractXmpNumber(xmpSegment, 'MicroVideoPresentationTimestampUs') ??
extractXmpAttributeNumber(xmpSegment, 'MotionPhotoPresentationTimestampUs') ??
extractXmpAttributeNumber(xmpSegment, 'MicroVideoPresentationTimestampUs') ??
null
}
}
if (!detectedMotion && offsetCandidates.size === 0) {
return null
}
let resolvedOffset: number | null = null
let videoSize: number | null = null let videoSize: number | null = null
const candidateList = Array.from(offsetCandidates) // Try standard format (Motion Photo 1.0 with ContainerDirectory)
for (const candidate of candidateList) { const containerDirectory = exifData?.ContainerDirectory as ContainerDirectoryItem[] | undefined
const possibleStarts = new Set<number>() if (containerDirectory && Array.isArray(containerDirectory)) {
possibleStarts.add(candidate) logger?.info('[motion-photo] Found ContainerDirectory, using standard format')
if (candidate < rawLength) {
possibleStarts.add(rawLength - candidate)
}
for (const start of possibleStarts) { // Find video item
if (start <= 0 || start >= rawLength - MIN_VIDEO_SIZE_BYTES) { for (const entry of containerDirectory) {
continue const item = entry.Item
} if (!item) continue
const chunk = rawImageBuffer.subarray(start) if (item.Semantic === 'MotionPhoto' && item.Length) {
if (validateMp4Buffer(chunk)) { // Video is stored at the end of file, Length bytes from the end
resolvedOffset = start const offset = rawLength - item.Length
videoSize = chunk.length if (offset > 0 && offset < rawLength - MIN_VIDEO_SIZE_BYTES) {
if (start !== candidate && logger?.debug) { const chunk = rawImageBuffer.subarray(offset)
logger.debug(`[motion-photo] Interpreted offset ${candidate} as start ${start} from file end`) if (validateMp4Buffer(chunk)) {
videoOffset = offset
videoSize = item.Length
logger?.success(
`[motion-photo] Found video via ContainerDirectory: offset=${offset}, size=${item.Length}`,
)
} else {
logger?.warn(`[motion-photo] Invalid MP4 at ContainerDirectory offset ${offset}`)
}
} }
break
} }
} }
if (resolvedOffset !== null) {
break
}
} }
// Fallback: scan for MP4 signature // Fallback to legacy format (MicroVideo with MicroVideoOffset)
if (resolvedOffset === null) { if (videoOffset === null && isMotionPhotoFlag) {
const searchWindowStart = Math.max(0, rawLength - 8 * 1024 * 1024) const legacyOffset = toNumber(exifData?.MicroVideoOffset)
let cursor = rawImageBuffer.indexOf(MP4_FTYP, searchWindowStart) if (legacyOffset !== null) {
while (cursor !== -1) { logger?.info('[motion-photo] Using legacy MicroVideoOffset format')
const potentialStart = cursor - 4
if (potentialStart > 0 && potentialStart < rawLength - MIN_VIDEO_SIZE_BYTES) { // Try both interpretations: from start and from end
const chunk = rawImageBuffer.subarray(potentialStart) const candidates = [legacyOffset, rawLength - legacyOffset].filter(
(offset) => offset > 0 && offset < rawLength - MIN_VIDEO_SIZE_BYTES,
)
for (const offset of candidates) {
const chunk = rawImageBuffer.subarray(offset)
if (validateMp4Buffer(chunk)) { if (validateMp4Buffer(chunk)) {
resolvedOffset = potentialStart videoOffset = offset
videoSize = chunk.length videoSize = chunk.length
logger?.info(`[motion-photo] Located MP4 via fallback scan at offset ${potentialStart}`) logger?.success(`[motion-photo] Found video via legacy offset: ${offset}`)
break break
} }
} }
cursor = rawImageBuffer.indexOf(MP4_FTYP, cursor + 1)
} }
} }
if (resolvedOffset === null || videoSize === null) { // No motion photo found
logger?.warn(`[motion-photo] Unable to locate MP4 after trying offsets ${candidateList.join(', ') || 'none'}`) if (videoOffset === null || videoSize === null) {
if (isMotionPhotoFlag) {
logger?.warn('[motion-photo] MotionPhoto flag set but no valid video found')
}
return null return null
} }
logger?.success(`[motion-photo] Detected Motion Photo at offset ${resolvedOffset}, video size ${videoSize} bytes`)
return { return {
isMotionPhoto: true, isMotionPhoto: true,
motionPhotoOffset: resolvedOffset, motionPhotoOffset: videoOffset,
motionPhotoVideoSize: videoSize, motionPhotoVideoSize: videoSize,
presentationTimestampUs: presentationTimestampUs ?? undefined, presentationTimestampUs: presentationTimestampUs ?? undefined,
} }

View File

@@ -155,9 +155,20 @@ export interface PickedExif {
// HDR 相关 // HDR 相关
MPImageType?: Tags['MPImageType'] MPImageType?: Tags['MPImageType']
UniformResourceName?: string
// 评分 // 评分
Rating?: number Rating?: number
// Motion Photo (XMP) related fields
MotionPhoto?: Tags['MotionPhoto']
MotionPhotoVersion?: Tags['MotionPhotoVersion']
MotionPhotoPresentationTimestampUs?: Tags['MotionPhotoPresentationTimestampUs']
ContainerDirectory?: Tags['ContainerDirectory']
MicroVideo?: Tags['MicroVideo']
MicroVideoVersion?: Tags['MicroVideoVersion']
MicroVideoOffset?: Tags['MicroVideoOffset']
MicroVideoPresentationTimestampUs?: Tags['MicroVideoPresentationTimestampUs']
} }
export interface ThumbnailResult { export interface ThumbnailResult {