diff --git a/ROADMAP.md b/ROADMAP.md index a83e1e27..4aeabad9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -208,6 +208,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - [x] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": `ImportTaskState.PolicyName` baked at creation (resolved best-effort via `StoragePolicyClient`), summary emits `dst_policy_name` so admin views of other users' tasks work without a policy lookup; `policyOptionCache` retyped to `StoragePolicyBrief[]` and populated from `getAllowedPolicies()` at session init as fallback for legacy tasks - [x] #199 (upstream #3584) — markdown editor lag: root cause was per-keystroke React re-renders (changedValue state fed back into the editor's initial-markdown prop) re-running every plugin's `update()` hook (RealmWithPlugins has a dep-less effect). `MarkdownEditor` is now `memo`'d with a `useMemo`'d plugins array + stable `translation`; `MarkdownViewer` keeps edits in a ref (read at save) and passes the immutable loaded content — zero re-renders per keystroke - [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet +- [x] #89 (upstream #3277) — Android Motion Photo preview: `util/motionPhoto.ts` extracts the MP4 appended at EOF of MicroVideo/MotionPhoto JPEGs — XMP `GCamera:MicroVideoOffset` or `Container:Directory` `video/mp4` `Item:Length`, fetched via Range requests (128 KiB head + video tail only), `ftyp` sanity-checked; `Photo.tsx` feeds the blob URL into the same LivePhotosKit player used for iOS live photos — badge + press-to-play for free; degrades silently on CORS/fetch failure - [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety) - [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics - [x] Download URL shuffling (#173) — `download_cdn_shuffle` distributes generated download URLs randomly across the site URL + `download_cdn_routes` endpoints (`setting.DownloadURLBase`, honors `UseFirstSiteUrl`); covers entity downloads, archive sessions, and redirect-type direct links; manual route picker hidden client-side while active diff --git a/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx index 12df7ab1..16b9b6cb 100644 --- a/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx +++ b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx @@ -7,6 +7,7 @@ import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts" import { useAppDispatch } from "../../../../redux/hooks.ts"; import { loadFileThumb } from "../../../../redux/thunks/file.ts"; import { getFileLinkedUri } from "../../../../util"; +import { fetchMotionPhotoVideoUrl, isMotionPhotoCandidate } from "../../../../util/motionPhoto.ts"; import { FileManagerIndex } from "../../../FileManager/FileManager.tsx"; import { LRUCache } from "../../../../util/lru.ts"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; @@ -154,12 +155,32 @@ export default function Photo({ }); }, []); - const loadLivePhoto = async (file: FileResponse, imgUrl: string) => { - if (!file.metadata?.[Metadata.live_photo]) { + const attachLivePhotoPlayer = (file: FileResponse, imgUrl: string, videoUrl: string) => { + const imgElement = document.getElementById(file.id); + if (!imgElement || !mountedRef.current) { return; } + const player = LivePhotosKit.Player(imgElement as HTMLElement); + playerRef.current = player; + player.photoSrc = imgUrl; + player.videoSrc = videoUrl; + player.proactivelyLoadsVideo = true; + }; + const loadLivePhoto = async (file: FileResponse, imgUrl: string) => { try { + if (!file.metadata?.[Metadata.live_photo]) { + // Android Motion Photo: a JPEG with an MP4 appended at EOF (#89). + if (!isMotionPhotoCandidate(file.name, file.size)) { + return; + } + const videoUrl = await fetchMotionPhotoVideoUrl(imgUrl); + if (videoUrl) { + attachLivePhotoPlayer(file, imgUrl, videoUrl); + } + return; + } + const fileExtended = await dispatch( getFileInfo( { @@ -183,14 +204,7 @@ export default function Photo({ }), ); - const imgElement = document.getElementById(file.id); - if (imgElement) { - const player = LivePhotosKit.Player(imgElement as HTMLElement); - playerRef.current = player; - player.photoSrc = imgUrl; - player.videoSrc = livePhotoEntityUrl.urls[0].url; - player.proactivelyLoadsVideo = true; - } + attachLivePhotoPlayer(file, imgUrl, livePhotoEntityUrl.urls[0].url); } catch (e) { console.error("Failed to load live photo:", e); } diff --git a/frontend/src/util/motionPhoto.ts b/frontend/src/util/motionPhoto.ts new file mode 100644 index 00000000..bf97bbdc --- /dev/null +++ b/frontend/src/util/motionPhoto.ts @@ -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 => { + 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; + } +};