Merge pull request #210 from Dvorinka/feat/android-motion-photo

feat(viewer): Android Motion Photo preview (#89)
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 1451c673b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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] #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 - [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 - [ ] #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] `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] 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 - [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

@ -7,6 +7,7 @@ import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts"
import { useAppDispatch } from "../../../../redux/hooks.ts"; import { useAppDispatch } from "../../../../redux/hooks.ts";
import { loadFileThumb } from "../../../../redux/thunks/file.ts"; import { loadFileThumb } from "../../../../redux/thunks/file.ts";
import { getFileLinkedUri } from "../../../../util"; import { getFileLinkedUri } from "../../../../util";
import { fetchMotionPhotoVideoUrl, isMotionPhotoCandidate } from "../../../../util/motionPhoto.ts";
import { FileManagerIndex } from "../../../FileManager/FileManager.tsx"; import { FileManagerIndex } from "../../../FileManager/FileManager.tsx";
import { LRUCache } from "../../../../util/lru.ts"; import { LRUCache } from "../../../../util/lru.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
@ -154,12 +155,32 @@ export default function Photo({
}); });
}, []); }, []);
const loadLivePhoto = async (file: FileResponse, imgUrl: string) => { const attachLivePhotoPlayer = (file: FileResponse, imgUrl: string, videoUrl: string) => {
if (!file.metadata?.[Metadata.live_photo]) { const imgElement = document.getElementById(file.id);
if (!imgElement || !mountedRef.current) {
return; 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 { 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( const fileExtended = await dispatch(
getFileInfo( getFileInfo(
{ {
@ -183,14 +204,7 @@ export default function Photo({
}), }),
); );
const imgElement = document.getElementById(file.id); attachLivePhotoPlayer(file, imgUrl, livePhotoEntityUrl.urls[0].url);
if (imgElement) {
const player = LivePhotosKit.Player(imgElement as HTMLElement);
playerRef.current = player;
player.photoSrc = imgUrl;
player.videoSrc = livePhotoEntityUrl.urls[0].url;
player.proactivelyLoadsVideo = true;
}
} catch (e) { } catch (e) {
console.error("Failed to load live photo:", e); console.error("Failed to load live photo:", e);
} }

@ -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…
Cancel
Save