Merge pull request #210 from Dvorinka/feat/android-motion-photo
feat(viewer): Android Motion Photo preview (#89)pull/3589/head
commit
1451c673b4
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Android Motion Photo (a.k.a. MicroVideo) support.
|
||||||
|
*
|
||||||
|
* Google/Samsung motion photos are a single JPEG with an MP4 appended at the
|
||||||
|
* end. XMP metadata in the JPEG header either carries `GCamera:MicroVideoOffset`
|
||||||
|
* (bytes from EOF to the start of the MP4) or a `Container:Directory` whose
|
||||||
|
* `video/mp4` item declares `Item:Length` (video is the last item).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// XMP packets live in the JPEG APP1 segment near the start of the file;
|
||||||
|
// 128 KiB of head data covers every layout seen in the wild.
|
||||||
|
const HEAD_SCAN_BYTES = 128 * 1024;
|
||||||
|
|
||||||
|
// Motion photos carry a multi-MB video tail; smaller JPEGs cannot be one.
|
||||||
|
const MIN_FILE_SIZE = 512 * 1024;
|
||||||
|
|
||||||
|
const MOTION_PHOTO_EXTENSIONS = new Set(["jpg", "jpeg"]);
|
||||||
|
|
||||||
|
export const isMotionPhotoCandidate = (name: string, size: number): boolean =>
|
||||||
|
MOTION_PHOTO_EXTENSIONS.has(name.toLowerCase().split(".").pop() ?? "") &&
|
||||||
|
size >= MIN_FILE_SIZE;
|
||||||
|
|
||||||
|
const bytesToLatin1 = (buf: ArrayBuffer): string => {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 8192) {
|
||||||
|
out += String.fromCharCode.apply(
|
||||||
|
null,
|
||||||
|
bytes.subarray(i, i + 8192) as unknown as number[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extracts the embedded video's byte length (counted from EOF) from XMP text.
|
||||||
|
const extractVideoLength = (text: string): number | null => {
|
||||||
|
const offset = text.match(/MicroVideoOffset="(\d+)"/);
|
||||||
|
if (offset) {
|
||||||
|
return parseInt(offset[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/MicroVideo|MotionPhoto/.test(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeIdx = text.indexOf("video/mp4");
|
||||||
|
if (mimeIdx < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The item element serializes Mime before Length; search forward only so a
|
||||||
|
// preceding image item's Length is never picked up.
|
||||||
|
const window = text.slice(mimeIdx, mimeIdx + 512);
|
||||||
|
const length = window.match(/Item:Length="(\d+)"/);
|
||||||
|
return length ? parseInt(length[1], 10) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MP4 files start with an `ftyp` box; guards against bogus XMP offsets.
|
||||||
|
const looksLikeMp4 = (buf: ArrayBuffer): boolean => {
|
||||||
|
if (buf.byteLength < 12) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const view = new DataView(buf, 4);
|
||||||
|
return view.getUint32(0) === 0x66747970; // "ftyp"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a blob URL for the embedded MP4 of a motion photo, or null when the
|
||||||
|
* image has no embedded video or the bytes cannot be fetched (CORS, network).
|
||||||
|
* Uses HTTP Range requests so at most the head + video tail are transferred.
|
||||||
|
*/
|
||||||
|
export const fetchMotionPhotoVideoUrl = async (
|
||||||
|
imageUrl: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const head = await fetch(imageUrl, {
|
||||||
|
headers: { Range: `bytes=0-${HEAD_SCAN_BYTES - 1}` },
|
||||||
|
});
|
||||||
|
if (!head.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const headBuf = await head.arrayBuffer();
|
||||||
|
|
||||||
|
const videoLength = extractVideoLength(bytesToLatin1(headBuf));
|
||||||
|
if (!videoLength || videoLength <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoBuf: ArrayBuffer;
|
||||||
|
if (head.status === 206 && headBuf.byteLength <= HEAD_SCAN_BYTES) {
|
||||||
|
const tail = await fetch(imageUrl, {
|
||||||
|
headers: { Range: `bytes=-${videoLength}` },
|
||||||
|
});
|
||||||
|
if (!tail.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
videoBuf = await tail.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
// Range ignored: the head fetch already holds the whole file.
|
||||||
|
if (headBuf.byteLength <= videoLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
videoBuf = headBuf.slice(headBuf.byteLength - videoLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!looksLikeMp4(videoBuf)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(new Blob([videoBuf], { type: "video/mp4" }));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in new issue