Release v0.2.7
Add Live Photo playback for paired MOV/MP4/M4V videos and embedded Google and Samsung JPEG clips, with hover/tap controls, viewer replay, and original-video range and cache responses. Show top-level albums and direct subfolders with breadcrumb navigation and nested album search. Add browser regression fixtures and update album navigation checks. Bump Rust and frontend versions and their lockfiles to 0.2.7. Validated: frontend and Rust builds, 121 Rust tests (2 network tests not run), 198 layout cases, and Chromium live-photo and album navigation checks.
This commit is contained in:
1 parent
8b40301f8e
commit
73847d8107
31 files changed
+2807
-62
No files matched your search
Generated
+12
-1
@@ -1685,7 +1685,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pixhelf"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1695,10 +1695,12 @@ dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"candle-transformers",
|
||||
"http-range-header",
|
||||
"image",
|
||||
"ipnet",
|
||||
"kamadak-exif",
|
||||
"natord",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
"serde",
|
||||
@@ -1800,6 +1802,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -14,10 +14,12 @@ candle-core = { version = "0.9.2", default-features = false }
|
||||
candle-nn = { version = "0.9.2", default-features = false }
|
||||
candle-transformers = { version = "0.9.2", default-features = false }
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
http-range-header = "0.4.2"
|
||||
ipnet = "2.12.0"
|
||||
kamadak-exif = "0.6.1"
|
||||
natord = "1.0.9"
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-webpki-roots", "stream"] }
|
||||
quick-xml = "0.41.0"
|
||||
rpassword = "7.4.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
|
||||
@@ -35,6 +35,8 @@ docker compose up -d
|
||||
## 使用与设置
|
||||
|
||||
- **浏览图片**:点击查看大图,桌面右键或手机长按打开菜单,可查看图片信息、查找相似图片或下载原图。
|
||||
- **浏览相册**:首页显示顶层文件夹,进入后显示直属子相册;可通过路径导航返回上层。搜索会查找所有层级,支持直接进入子相册;同名文件夹通过完整路径区分。
|
||||
- **实况照片**:支持 JPEG、PNG、WebP 与同名 MOV/MP4/M4V 配对,以及 Google/Samsung 二合一 JPG。桌面悬停播放;手机首次轻点播放,之后再点打开大图,播完也有效;播放过另一张实况后,回到之前的图片需先点一次播放。大图自动播放一遍,之后单击重播。全页面最多同时播放一个,等待时显示细进度条。浏览器可缓存原视频,不转码、不生成服务端视频缓存文件,也不依赖 FFmpeg。HEVC 需浏览器和设备支持;HEIC/HEIF 照片暂不支持,可导出为 JPEG 并保留同名视频。
|
||||
- **以图搜图**:侧栏“相似图片”支持上传不超过 20 MB 的 JPG、PNG、WebP;查询图片仅用于搜索,不写入图库。
|
||||
- **账号安全**:设置中可修改用户名和密码,保存后所有设备需重新登录。
|
||||
- **访客模式**:默认关闭。开启“免登录浏览”后,来访者无需密码即可浏览、搜图和下载;账号设置与外部存储仅限管理员。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"dependencies": {
|
||||
"preact": "^10.27.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -13,10 +13,15 @@
|
||||
"auth-check": "node scripts/auth-check.mjs",
|
||||
"storage-check": "node scripts/storage-check.mjs",
|
||||
"albums-check": "node scripts/albums-check.mjs",
|
||||
"album-hierarchy-check": "node scripts/album-hierarchy-check.mjs",
|
||||
"visual-check": "node scripts/visual-check.mjs",
|
||||
"navigation-motion-check": "node scripts/navigation-motion-check.mjs",
|
||||
"sidebar-resize-check": "node scripts/sidebar-resize-check.mjs",
|
||||
"viewer-resize-check": "node scripts/viewer-resize-check.mjs",
|
||||
"live-photo-check": "node scripts/live-photo-app-check.mjs",
|
||||
"live-photo-interaction-check": "node scripts/live-photo-interaction-check.mjs",
|
||||
"live-photo-app-check": "node scripts/live-photo-app-check.mjs",
|
||||
"live-photo-webkit-check": "node scripts/live-photo-app-check.mjs --webkit",
|
||||
"viewer-similar-check": "node scripts/viewer-similar-check.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Exercise album navigation with in-memory data, without starting a service.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const fixture = await build({
|
||||
stdin: { resolveDir: fileURLToPath(new URL("../", import.meta.url)), loader: "tsx", contents: `
|
||||
import { render } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
import { AlbumChildren, AlbumHeading, AlbumsView } from "./src/AlbumsView";
|
||||
const albums = ["Live", "Live/Apple", "Live/Apple/2023", "Live/Samsung", "Live2", "收藏", "收藏/Apple"]
|
||||
.map(path => ({ path, name: path.split("/").at(-1), count: 1, cover: null }));
|
||||
function Fixture() {
|
||||
const [path, setPath] = useState(""), [search, setSearch] = useState("");
|
||||
const open = path => { setPath(path); setSearch(""); };
|
||||
const album = albums.find(album => album.path === path);
|
||||
return <main>
|
||||
{album ? <><AlbumHeading album={album} onOpen={open} /><AlbumChildren albums={albums} path={path} onOpen={open} /></> : <>
|
||||
<input aria-label="搜索相册" value={search} onInput={event => setSearch(event.currentTarget.value)} />
|
||||
<AlbumsView albums={albums} search={search} onOpen={open} />
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
render(<Fixture />, document.getElementById("root"));
|
||||
` },
|
||||
bundle: true, write: false, format: "iife", jsx: "automatic", jsxImportSource: "preact",
|
||||
});
|
||||
const css = (await Promise.all(["styles.css", "albums.css"].map(name => readFile(new URL(`../src/${name}`, import.meta.url), "utf8")))).join("\n");
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? "/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
|
||||
headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.setContent('<base href="https://pixhelf.test/"><div id="root"></div>');
|
||||
await page.addStyleTag({ content: css });
|
||||
await page.addScriptTag({ content: fixture.outputFiles[0].text });
|
||||
const paths = () => page.locator("[data-album-path]").evaluateAll(cards => cards.map(card => card.dataset.albumPath));
|
||||
const album = path => page.locator(`[data-album-path="${path}"]`);
|
||||
assert.deepEqual(await paths(), ["Live", "Live2", "收藏"]);
|
||||
await album("Live").click();
|
||||
assert.deepEqual(await paths(), ["Live/Apple", "Live/Samsung"]);
|
||||
assert.equal(await album("Live/Apple").getAttribute("href"), "/?view=albums&album=Live%2FApple");
|
||||
await album("Live/Apple").click();
|
||||
assert.deepEqual(await paths(), ["Live/Apple/2023"]);
|
||||
await album("Live/Apple/2023").click();
|
||||
assert.equal(await page.locator(".album-card").count(), 0);
|
||||
await page.getByRole("navigation", { name: "相册路径" }).getByRole("link", { name: "Apple", exact: true }).click();
|
||||
assert.deepEqual(await paths(), ["Live/Apple/2023"]);
|
||||
await page.getByRole("navigation", { name: "相册路径" }).getByRole("link", { name: "相册", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "搜索相册" }).fill("Apple");
|
||||
assert.deepEqual(await paths(), ["Live/Apple", "Live/Apple/2023", "收藏/Apple"]);
|
||||
await album("收藏/Apple").click();
|
||||
assert.equal(await page.getByRole("heading", { name: "Apple", exact: true }).count(), 1);
|
||||
assert.equal(await page.locator(".album-card").count(), 0);
|
||||
await page.getByRole("navigation", { name: "相册路径" }).getByRole("link", { name: "相册", exact: true }).click();
|
||||
assert.deepEqual(await paths(), ["Live", "Live2", "收藏"]);
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
console.log(JSON.stringify({ noServer: true, rootFolders: true, directChildren: true, breadcrumbs: true, nestedSearch: true, distinctSameNames: true }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -54,7 +54,7 @@ async function navigate(page, title) {
|
||||
await page.locator(".gallery-sidebar-toggle").click();
|
||||
}
|
||||
const panel = page.viewportSize().width <= 720 ? ".mobile-sidebar" : ".desktop-sidebar";
|
||||
assert.deepEqual(await page.locator(`${panel} .album-link strong`).allTextContents(), ["图片", "相册", "外部存储"]);
|
||||
assert.deepEqual(await page.locator(`${panel} .album-link strong`).allTextContents(), ["图片", "相册", "相似图片", "外部存储"]);
|
||||
await page.locator(`${panel} .album-link`).getByText(title, { exact: true }).click();
|
||||
if (title === "相册") await page.locator(".albums-grid").waitFor();
|
||||
else await page.locator(".image-card").nth(24).waitFor();
|
||||
@@ -63,11 +63,16 @@ async function navigate(page, title) {
|
||||
}
|
||||
}
|
||||
|
||||
function card(page, path) { return page.locator(`.album-card[data-album-path=${JSON.stringify(path)}]`); }
|
||||
function card(page, path) { return page.locator(`[data-album-path=${JSON.stringify(path)}]`); }
|
||||
async function waitCount(page, selector, count) {
|
||||
await page.waitForFunction(({ selector, count }) => document.querySelectorAll(selector).length === count, { selector, count });
|
||||
}
|
||||
async function openAlbum(page, path) {
|
||||
if (!await card(page, path).count()) {
|
||||
const parent = path.slice(0, path.lastIndexOf("/"));
|
||||
assert.ok(parent && parent !== path, `album is not reachable: ${path}`);
|
||||
await openAlbum(page, parent);
|
||||
}
|
||||
await card(page, path).click();
|
||||
await page.locator(".album-heading").waitFor();
|
||||
await page.waitForFunction(() => document.querySelector(".content").getAttribute("aria-busy") === "false");
|
||||
@@ -118,9 +123,9 @@ try {
|
||||
assert.equal(await page.locator(".album-heading").count(), 0, "library no longer starts with the waterfall");
|
||||
await navigate(page, "相册");
|
||||
const requestsBeforeCatalog = imageRequests.length;
|
||||
await waitCount(page, ".album-card", 10);
|
||||
await waitCount(page, ".album-card", 6);
|
||||
assert.equal(await page.locator(".image-card").count(), 0);
|
||||
assert.deepEqual(await page.locator(".album-card p").allTextContents(), summary.albums.map(album => album.path));
|
||||
assert.deepEqual(await page.locator(".album-card p").allTextContents(), summary.albums.filter(album => !album.path.includes("/")).map(album => album.path));
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".album-cover img")].filter(img => img.getBoundingClientRect().top < innerHeight).every(img => img.complete && img.naturalWidth > 0));
|
||||
await page.screenshot({ path: join(tmpdir(), `pixhelf-albums-${width}.png`), fullPage: true });
|
||||
@@ -133,7 +138,7 @@ try {
|
||||
await page.getByText("没有找到相册", { exact: true }).waitFor();
|
||||
await page.locator(".clear-search").click();
|
||||
await page.locator(".search-toggle").click();
|
||||
await waitCount(page, ".album-card", 10);
|
||||
await waitCount(page, ".album-card", 6);
|
||||
assert.equal(imageRequests.length, requestsBeforeCatalog, "catalog search fetched images");
|
||||
|
||||
await openAlbum(page, "空相册");
|
||||
@@ -169,7 +174,9 @@ try {
|
||||
await page.locator(".image-viewer").waitFor({ state: "detached" });
|
||||
assert.equal(new URL(page.url()).searchParams.get("album"), "旅行/山野");
|
||||
await page.goBack();
|
||||
await page.locator(".albums-grid").waitFor();
|
||||
await page.getByRole("heading", { name: "旅行", exact: true }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get("album"), "旅行");
|
||||
assert.equal(await card(page, "旅行/山野").count(), 1);
|
||||
await page.goForward();
|
||||
await page.getByRole("heading", { name: "山野", exact: true }).waitFor();
|
||||
await page.goForward();
|
||||
@@ -193,7 +200,7 @@ try {
|
||||
await navigate(page, "相册");
|
||||
release();
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
await waitCount(page, ".album-card", 10);
|
||||
await waitCount(page, ".album-card", 6);
|
||||
assert.equal(await page.locator(".image-card").count(), 0);
|
||||
await mkdir(join(gallery, "新增空相册"));
|
||||
await card(page, "新增空相册").waitFor({ timeout: 20_000 });
|
||||
@@ -201,7 +208,7 @@ try {
|
||||
await page.getByText("相册暂无图片", { exact: true }).waitFor();
|
||||
await rm(join(gallery, "新增空相册"), { recursive: true });
|
||||
await page.locator(".albums-grid").waitFor({ timeout: 20_000 });
|
||||
await waitCount(page, ".album-card", 10);
|
||||
await waitCount(page, ".album-card", 6);
|
||||
assert.equal(new URL(page.url()).searchParams.get("album"), null);
|
||||
}
|
||||
await navigate(page, "相册");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
The generated `live-photo.jpg` / `live-photo.mp4` pair contains a green poster
|
||||
and an H.264 animation. `live-photo-hevc.mov` is an HEVC fixture with silent AAC
|
||||
audio, also used by the Rust response tests. No test requires FFmpeg.
|
||||
|
||||
## Offline browser checks
|
||||
|
||||
Run from `frontend` after installing Playwright Chromium or WebKit and its runtime
|
||||
dependencies. All responses come from browser routes; no app or HTTP server starts.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run live-photo-interaction-check
|
||||
npm run live-photo-check
|
||||
```
|
||||
|
||||
- `live-photo-interaction-check` mounts the real components and checks touch
|
||||
feedback, playback/opening order, blocked/interrupted playback, buffered stall
|
||||
recovery, decoder cleanup and unsupported-video fallback.
|
||||
- `live-photo-check` (also `live-photo-app-check`) checks the built application at
|
||||
desktop and phone sizes. Screenshot pixels must show a moving picture; playback
|
||||
clocks alone cannot pass. It covers loading indicators, opening during loading
|
||||
or after playback, A → B → A interaction, viewer navigation and click/tap replay.
|
||||
- `live-photo-webkit-check` runs the same application checks in WebKit.
|
||||
|
||||
Both suites share browser setup, sample loading and original-byte Range responses
|
||||
in `../live-photo-fixture.mjs`. Optional environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `PIXHELF_TEST_ENGINE=webkit` | Use WebKit in either suite. |
|
||||
| `PIXHELF_LIVE_SAMPLE` | Extensionless JPEG/MOV pair or Samsung SEF JPEG path. |
|
||||
| `PIXHELF_TEST_WIDTH=390` | Limit app checks to one viewport width. |
|
||||
| `PIXHELF_TEST_NATIVE_VIDEO_BLACK=1` | Black out the native viewer video to verify canvas playback. |
|
||||
| `PLAYWRIGHT_CHROMIUM_EXECUTABLE` | Override the installed Chromium executable. |
|
||||
|
||||
For example, set `PIXHELF_LIVE_SAMPLE` to
|
||||
`/path/to/Live/Apple/2023-12-27_12-30-56` or
|
||||
`/path/to/Live/motionphoto/samsung-one-ui-6`. The browser must decode the original
|
||||
codec. Chromium replay checks require no new media requests for the retained
|
||||
player; WebKit checks also verify orientation after replacing its decoder.
|
||||
|
||||
## Original bytes and caching
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
PIXHELF_LIVE_SAMPLES="$PWD/pic/Live" cargo test --locked --offline original_live_samples_bypass_old_cache_and_match_source_bytes -- --ignored --nocapture
|
||||
```
|
||||
|
||||
This checks full and ranged Apple/Samsung responses against original HEVC bytes,
|
||||
rejects legacy preview URLs/validators, and verifies bodyless 304 revalidation.
|
||||
The regular Rust tests cover pairing, XMP/SEF extraction and authentication before
|
||||
cached/range responses, including logout. Browser routing disables HTTP caching,
|
||||
so browser checks verify playback and player reuse; Rust checks verify response
|
||||
caching. `PIXHELF_LIVE_SAMPLES` is the sample directory for the Rust audit;
|
||||
`PIXHELF_LIVE_SAMPLE` selects one file for the browser checks.
|
||||
|
||||
`npm run album-hierarchy-check` checks folder navigation using an in-memory album
|
||||
list, also without a server.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 881 B |
Binary file not shown.
@@ -0,0 +1,343 @@
|
||||
// Exercise the production app with browser routes only: no HTTP listener or
|
||||
// backend process. Build the frontend before running this check.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { engine, isChromium, launchBrowser, loadSample, serveMotion, imageDimensions } from "./live-photo-fixture.mjs";
|
||||
|
||||
const [html, js, css, filler, sample] = await Promise.all([
|
||||
readFile(new URL("../dist/index.html", import.meta.url), "utf8"),
|
||||
readFile(new URL("../dist/assets/app.js", import.meta.url)),
|
||||
readFile(new URL("../dist/assets/app.css", import.meta.url)),
|
||||
readFile(new URL("fixtures/live-photo.jpg", import.meta.url)),
|
||||
loadSample(),
|
||||
]);
|
||||
const { name: sampleName, poster } = sample;
|
||||
const blackNativeSurface = process.env.PIXHELF_TEST_NATIVE_VIDEO_BLACK === "1";
|
||||
const browser = await launchBrowser();
|
||||
const probe = await browser.newPage();
|
||||
let dimensions;
|
||||
try {
|
||||
dimensions = await imageDimensions(probe, poster);
|
||||
} finally { await probe.close(); }
|
||||
const images = Array.from({ length: 32 }, (_, i) => {
|
||||
const live = i === 0 || i === 26 || i === 27;
|
||||
return { id: `photo-${i}`, name: live ? `${sampleName}.jpg` : `image-${i}.jpg`,
|
||||
...(live ? dimensions : { width: 160, height: 120 }),
|
||||
...(live ? { motion: `/api/images/photo-${i}/motion/original/fixture` } : {}),
|
||||
};
|
||||
});
|
||||
const summary = { total: images.length, revision: "offline", albums: ["Live", "Live/Apple", "Live/Samsung"].map(path => ({
|
||||
path, name: path.split("/").at(-1), count: images.length, cover: "photo-0",
|
||||
})) };
|
||||
const status = { total: images.length, ready: images.length, queued: 0, processing: 0, failed: 0,
|
||||
initialBatchReady: true, backgroundComplete: true,
|
||||
textSearch: { enabled: false, total: images.length, ready: 0, queued: 0, processing: 0, failed: 0, backgroundComplete: true },
|
||||
};
|
||||
const pageData = { items: images, total: images.length, offset: 0, limit: 60, nextOffset: null };
|
||||
const documentHtml = html.replace("__PIXHELF_AUTH__", JSON.stringify({ enabled: false, authenticated: false }))
|
||||
.replace("__PIXHELF_BOOTSTRAP__", JSON.stringify({ summary, status, images: pageData }));
|
||||
|
||||
async function state(page) {
|
||||
return page.evaluate(() => ({
|
||||
events: window.mediaEvents.slice(-24), inputs: window.inputEvents.slice(-16), scroll: scrollY,
|
||||
layers: [...document.querySelectorAll(".live-photo")].map(layer => {
|
||||
const video = layer.querySelector("video");
|
||||
return {
|
||||
parent: layer.parentElement.className, playing: layer.dataset.playing, loading: layer.dataset.loading, failed: layer.dataset.failed,
|
||||
rect: layer.getBoundingClientRect().toJSON(), inert: !!layer.closest("[inert]"),
|
||||
video: video && {
|
||||
paused: video.paused, time: video.currentTime, width: video.videoWidth,
|
||||
ready: video.readyState, network: video.networkState, src: video.currentSrc,
|
||||
buffered: Array.from({ length: video.buffered.length }, (_, i) => [video.buffered.start(i), video.buffered.end(i)]),
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function playing(card) {
|
||||
await card.page().waitForFunction(element => {
|
||||
const video = element.querySelector("video");
|
||||
return element.querySelector(".live-photo")?.dataset.playing === "true"
|
||||
&& element.querySelector(".live-photo")?.dataset.loading === "false"
|
||||
&& video?.videoWidth > 0 && video.currentTime > 0 && !video.paused;
|
||||
}, await card.elementHandle(), { timeout: 12_000 });
|
||||
}
|
||||
|
||||
async function finished(card) {
|
||||
const video = await card.locator("video").elementHandle();
|
||||
assert.equal(await video.evaluate(video => video.loop), false, "live photos must not loop");
|
||||
await card.page().waitForFunction(video => window.endedVideos.has(video), video, { timeout: 10_000 });
|
||||
// The pause handler must not turn a natural ending into another play request.
|
||||
await card.page().evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
assert.equal(await video.evaluate(video => video.paused), true);
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
}
|
||||
|
||||
async function visiblePicture(card, verifyMotion = false) {
|
||||
const rect = await card.boundingBox();
|
||||
assert.ok(rect && rect.width > 80 && rect.height > 80);
|
||||
const capture = async () => {
|
||||
const png = await card.page().screenshot({ clip: {
|
||||
x: rect.x + rect.width * 0.15, y: rect.y + rect.height * 0.15,
|
||||
width: rect.width * 0.7, height: rect.height * 0.7,
|
||||
} });
|
||||
// Inspect the rendered screenshot, not drawImage(video), which can succeed
|
||||
// even when a compositor surface fails to paint after a DOM move.
|
||||
return card.page().evaluate(async base64 => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${base64}`;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 24;
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(image, 0, 0, 24, 24);
|
||||
const pixels = context.getImageData(0, 0, 24, 24).data;
|
||||
return [...pixels].filter((_, i) => i % 4 !== 3);
|
||||
}, png.toString("base64"));
|
||||
};
|
||||
const pixels = await capture();
|
||||
const brightness = pixels.reduce((sum, value) => sum + value, 0) / pixels.length;
|
||||
assert.ok(brightness > 25, `the visible photo is black (brightness ${brightness})`);
|
||||
if (verifyMotion) {
|
||||
let changed = false;
|
||||
for (let attempt = 0; attempt < 3 && !changed; attempt++) {
|
||||
const before = await card.locator("video").evaluate(video => video.currentTime);
|
||||
await card.page().waitForFunction(({ element, before }) => {
|
||||
const video = element.querySelector("video");
|
||||
return video.currentTime < before || video.currentTime - before > 0.4;
|
||||
}, { element: await card.elementHandle(), before });
|
||||
const next = await capture();
|
||||
changed = next.reduce((sum, value, i) => sum + Math.abs(value - pixels[i]), 0) / pixels.length > 0.2;
|
||||
}
|
||||
assert.ok(changed, "the video clock advanced but the visible photo stayed still");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const width of process.env.PIXHELF_TEST_WIDTH ? [Number(process.env.PIXHELF_TEST_WIDTH)] : [1440, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 }, isMobile: width <= 720, hasTouch: width <= 720 });
|
||||
const errors = [];
|
||||
const motionRequests = [];
|
||||
let holdMotion = null;
|
||||
let releaseMotion = () => {};
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
window.mediaEvents = [];
|
||||
window.inputEvents = [];
|
||||
for (const type of ["touchstart", "touchend", "pointerdown", "mousedown", "click"]) {
|
||||
document.addEventListener(type, event => window.inputEvents.push({ type, detail: event.detail,
|
||||
pointerType: event.pointerType, target: event.target.className }), true);
|
||||
}
|
||||
window.endedVideos = new WeakSet();
|
||||
for (const event of ["loadstart", "loadeddata", "playing", "pause", "ended", "error", "emptied", "resize", "seeking", "seeked", "waiting", "timeupdate"]) {
|
||||
document.addEventListener(event, e => {
|
||||
if (!(e.target instanceof HTMLVideoElement)) return;
|
||||
const video = e.target;
|
||||
if (event === "ended") window.endedVideos.add(video);
|
||||
if (event === "playing") window.endedVideos.delete(video);
|
||||
window.mediaEvents.push({ event, parent: video.closest(".viewer-media") ? "viewer" : "gallery",
|
||||
paused: video.paused, time: video.currentTime, width: video.videoWidth, ready: video.readyState,
|
||||
seeking: video.seeking, loading: video.closest(".live-photo")?.dataset.loading,
|
||||
shown: video.closest(".live-photo")?.dataset.playing,
|
||||
error: video.error?.message });
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
await page.route("https://pixhelf.test/**", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
if (path === "/") return route.fulfill({ contentType: "text/html", body: documentHtml });
|
||||
if (path === "/assets/app.js") return route.fulfill({ contentType: "text/javascript", body: js });
|
||||
if (path === "/assets/app.css") return route.fulfill({ contentType: "text/css", body: css });
|
||||
if (path === "/api/gallery") return route.fulfill({ json: summary });
|
||||
if (path === "/api/status") return route.fulfill({ json: status });
|
||||
if (path === "/api/images") return route.fulfill({ json: pageData });
|
||||
const image = images.find(image => path.startsWith(`/api/images/${image.id}/`) || path === `/api/images/${image.id}`);
|
||||
if (image && path.endsWith(`/${image.id}`)) return route.fulfill({ json: image });
|
||||
if (image && ["thumbnail", "original"].some(kind => path === `/api/images/${image.id}/${kind}`)) return route.fulfill({ contentType: "image/jpeg", body: image.motion ? poster : filler });
|
||||
if (image && path.endsWith("/similar")) return route.fulfill({ json: { ...pageData, items: [], total: 0 } });
|
||||
if (image && path.endsWith("/details")) return route.fulfill({ json: { fileSize: poster.length, modifiedMs: 0, exif: [],
|
||||
histogram: Object.fromEntries(["red", "green", "blue", "luminance"].map(key => [key, Array(256).fill(0)])),
|
||||
} });
|
||||
if (image && path.endsWith("/motion/original/fixture")) {
|
||||
const request = { path, range: route.request().headers().range, fulfilled: false };
|
||||
motionRequests.push(request);
|
||||
if (holdMotion) await holdMotion;
|
||||
await serveMotion(route, sample);
|
||||
request.fulfilled = true;
|
||||
return;
|
||||
}
|
||||
errors.push(`unhandled request: ${url.pathname}`);
|
||||
return route.fulfill({ status: 404, json: {} });
|
||||
});
|
||||
try {
|
||||
await page.goto("https://pixhelf.test/", { waitUntil: "networkidle" });
|
||||
if (blackNativeSurface) {
|
||||
// Fault injection: decoding continues, but the native video surface
|
||||
// paints black. CSS does not affect drawImage(video) frame pixels.
|
||||
await page.addStyleTag({ content: ".viewer-media .live-photo-video { filter: brightness(0) !important; }" });
|
||||
}
|
||||
for (const index of [0, 26]) {
|
||||
const card = page.locator(`.image-card[data-image-id="photo-${index}"]`);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.waitForFunction(element => element.dataset.loaded === "true", await card.elementHandle());
|
||||
if (index === 0) holdMotion = new Promise(resolve => { releaseMotion = resolve; });
|
||||
if (width > 720) await card.hover();
|
||||
else await card.locator(".photo-card-open").tap();
|
||||
assert.equal(await page.locator(".image-viewer").count(), 0, "the first tap must play in the gallery");
|
||||
if (holdMotion) {
|
||||
await card.locator(".live-photo-loading").waitFor({ state: "visible" });
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
await card.screenshot({ path: `/tmp/pixhelf-live-loading-card-${sampleName}-${engine.name()}-${width}.png` });
|
||||
if (width <= 720) {
|
||||
await card.locator(".photo-card-open").tap();
|
||||
const loadingViewer = page.locator(".viewer-media:not(.viewer-swipe-outgoing)");
|
||||
await loadingViewer.waitFor();
|
||||
await loadingViewer.locator(".live-photo-loading").waitFor({ state: "visible" });
|
||||
assert.equal(await loadingViewer.getAttribute("data-image-id"), `photo-${index}`, "a second tap while loading must open the viewer");
|
||||
releaseMotion();
|
||||
holdMotion = null;
|
||||
await playing(loadingViewer);
|
||||
await visiblePicture(loadingViewer, true);
|
||||
await page.locator(".viewer-close").click();
|
||||
await page.locator(".image-viewer").waitFor({ state: "detached" });
|
||||
await page.locator(".viewer-return-layer").waitFor({ state: "detached" });
|
||||
// Playing another photo resets A's next tap to preview, even when
|
||||
// that other photo has already finished before returning to A.
|
||||
const other = page.locator('.image-card[data-image-id="photo-27"]');
|
||||
await other.scrollIntoViewIfNeeded();
|
||||
await page.waitForFunction(element => element.dataset.loaded === "true", await other.elementHandle());
|
||||
await other.locator(".photo-card-open").tap();
|
||||
await playing(other);
|
||||
await finished(other);
|
||||
await card.locator(".photo-card-open").tap();
|
||||
} else {
|
||||
releaseMotion();
|
||||
holdMotion = null;
|
||||
}
|
||||
}
|
||||
await playing(card);
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-loading"), "false");
|
||||
assert.equal(await page.locator(".image-viewer").count(), 0, "the first mobile tap should only play the live photo");
|
||||
await visiblePicture(card);
|
||||
if (index === 0) {
|
||||
const galleryPlayer = await card.locator("video").elementHandle();
|
||||
const galleryDimensions = await galleryPlayer.evaluate(video => [video.videoWidth, video.videoHeight]);
|
||||
await finished(card);
|
||||
if (width > 720) {
|
||||
const beforeReplay = motionRequests.length;
|
||||
await page.mouse.move(0, 0);
|
||||
await card.hover();
|
||||
await playing(card);
|
||||
await visiblePicture(card, true);
|
||||
await card.screenshot({ path: `/tmp/pixhelf-live-replay-card-${sampleName}-${engine.name()}-${width}.png` });
|
||||
assert.deepEqual(await card.locator("video").evaluate(video => [video.videoWidth, video.videoHeight]), galleryDimensions, "replay must retain the original orientation");
|
||||
if (isChromium) {
|
||||
assert.equal(await card.locator("video").evaluate((video, previous) => video === previous, galleryPlayer), true, "replay should keep the current photo's player");
|
||||
assert.equal(motionRequests.length, beforeReplay, "gallery replay should not request the original clip again");
|
||||
}
|
||||
assert.equal(await page.locator(".image-viewer").count(), 0, "hovering again should replay a completed gallery clip");
|
||||
}
|
||||
}
|
||||
if (index === 0) holdMotion = new Promise(resolve => { releaseMotion = resolve; });
|
||||
if (width > 720) await card.locator(".photo-card-open").click();
|
||||
else await card.locator(".photo-card-open").tap();
|
||||
const viewer = page.locator(".viewer-media:not(.viewer-swipe-outgoing)");
|
||||
await viewer.waitFor();
|
||||
assert.equal(await viewer.getAttribute("data-image-id"), `photo-${index}`, "the most recently played photo must open on the next tap, including after playback ends");
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector(".image-viewer")).opacity === "1");
|
||||
if (holdMotion) {
|
||||
assert.equal(await viewer.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
await viewer.locator(".live-photo-loading").waitFor({ state: "visible" });
|
||||
await visiblePicture(viewer);
|
||||
await page.screenshot({ path: `/tmp/pixhelf-live-loading-viewer-${sampleName}-${engine.name()}-${width}.png` });
|
||||
releaseMotion();
|
||||
holdMotion = null;
|
||||
}
|
||||
await playing(viewer);
|
||||
assert.equal(await viewer.locator(".live-photo").getAttribute("data-loading"), "false");
|
||||
await visiblePicture(viewer, true);
|
||||
await page.screenshot({ path: `/tmp/pixhelf-live-app-${sampleName}-${engine.name()}-${width}-${index}.png` });
|
||||
if (index === 0) {
|
||||
const viewerPlayer = await viewer.locator("video").elementHandle();
|
||||
const viewerDimensions = await viewerPlayer.evaluate(video => [video.videoWidth, video.videoHeight]);
|
||||
await finished(viewer);
|
||||
const beforeReplay = motionRequests.length;
|
||||
await page.mouse.move(0, 0);
|
||||
await viewer.hover();
|
||||
await viewer.dispatchEvent("pixhelf:card-touch");
|
||||
// Re-entering the viewport after completion must not auto-replay.
|
||||
for (const display of ["none", ""]) {
|
||||
await viewer.evaluate(async (media, display) => {
|
||||
media.style.display = display;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}, display);
|
||||
}
|
||||
assert.equal(await viewer.locator(".live-photo").getAttribute("data-playing"), "false", "hover, contact and visibility must not replay a completed viewer clip");
|
||||
if (width > 720) {
|
||||
const rect = await viewer.boundingBox();
|
||||
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + 25, y, { steps: 2 });
|
||||
await page.mouse.move(x, y, { steps: 2 });
|
||||
await page.mouse.up();
|
||||
assert.equal(await viewer.locator(".live-photo").getAttribute("data-playing"), "false", "a drag returning to its starting point is not a replay tap");
|
||||
await viewer.click();
|
||||
} else await viewer.tap();
|
||||
await playing(viewer);
|
||||
await visiblePicture(viewer, true);
|
||||
assert.deepEqual(await viewer.locator("video").evaluate(video => [video.videoWidth, video.videoHeight]), viewerDimensions);
|
||||
await page.screenshot({ path: `/tmp/pixhelf-live-replay-viewer-${sampleName}-${engine.name()}-${width}.png` });
|
||||
await finished(viewer);
|
||||
if (isChromium) {
|
||||
assert.equal(await viewer.locator("video").evaluate((video, previous) => video === previous, viewerPlayer), true);
|
||||
assert.equal(motionRequests.length, beforeReplay, "viewer replay should not request the original clip again");
|
||||
}
|
||||
}
|
||||
if (index === 26) {
|
||||
for (const direction of ["next", "previous"]) {
|
||||
const previousId = await viewer.getAttribute("data-image-id");
|
||||
const control = page.locator(`.viewer-${direction}`);
|
||||
if (width > 720) await control.click();
|
||||
else if (isChromium) {
|
||||
const client = await page.context().newCDPSession(page);
|
||||
const rect = await viewer.boundingBox();
|
||||
const point = { id: 1, x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] });
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ ...point, x: point.x + (direction === "next" ? -120 : 120) }] });
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await client.detach();
|
||||
} else await page.keyboard.press(direction === "next" ? "ArrowRight" : "ArrowLeft");
|
||||
await page.waitForFunction(id => document.querySelector(".viewer-media:not(.viewer-swipe-outgoing)")?.dataset.imageId !== id, previousId);
|
||||
await playing(viewer);
|
||||
await page.waitForFunction(() => !document.querySelector(".image-viewer").hasAttribute("data-swipe-direction"));
|
||||
await visiblePicture(viewer, true);
|
||||
assert.equal(await page.locator("video").count(), 1);
|
||||
}
|
||||
}
|
||||
await page.locator(".viewer-close").click();
|
||||
await page.locator(".image-viewer").waitFor({ state: "detached" });
|
||||
await page.locator(".viewer-return-layer").waitFor({ state: "detached" });
|
||||
}
|
||||
if (width <= 720) {
|
||||
const still = page.locator('.image-card[data-image-id="photo-1"]');
|
||||
await still.locator(".photo-card-open").tap();
|
||||
await page.locator('.viewer-media[data-image-id="photo-1"]').waitFor();
|
||||
await page.locator(".viewer-close").click();
|
||||
await page.locator(".image-viewer").waitFor({ state: "detached" });
|
||||
}
|
||||
await page.goto("https://pixhelf.test/?view=albums&album=Live", { waitUntil: "networkidle" });
|
||||
await page.locator(".album-folder").first().waitFor();
|
||||
await page.screenshot({ path: `/tmp/pixhelf-album-folders-${engine.name()}-${width}.png` });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`${engine.name()}: ${sampleName}, ${width}px app checks passed${blackNativeSurface ? " (canvas fallback)" : ""}`);
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ motionRequests }, null, 2));
|
||||
console.error(JSON.stringify(await state(page), null, 2));
|
||||
await page.screenshot({ path: `/tmp/pixhelf-live-app-failure-${sampleName}-${engine.name()}-${width}.png` });
|
||||
throw error;
|
||||
} finally { releaseMotion(); await page.close(); }
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { chromium, webkit } from "playwright-core";
|
||||
|
||||
export const engine = process.argv.includes("--webkit") || process.env.PIXHELF_TEST_ENGINE === "webkit" ? webkit : chromium;
|
||||
export const isChromium = engine === chromium;
|
||||
|
||||
export function launchBrowser() {
|
||||
return engine.launch(isChromium ? {
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? chromium.executablePath(),
|
||||
headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
} : { headless: true });
|
||||
}
|
||||
|
||||
export async function loadSample() {
|
||||
const source = process.env.PIXHELF_LIVE_SAMPLE;
|
||||
const [poster, pairedClip] = await Promise.all([
|
||||
readFile(source ? `${source}.jpg` : new URL("fixtures/live-photo.jpg", import.meta.url)),
|
||||
readFile(source ? `${source}.mov` : new URL("fixtures/live-photo.mp4", import.meta.url))
|
||||
.catch(error => { if (source && error.code === "ENOENT") return null; throw error; }),
|
||||
]);
|
||||
return {
|
||||
name: source ? basename(source) : "fixture", poster,
|
||||
clip: pairedClip ?? samsungClip(poster),
|
||||
contentType: source && pairedClip ? "video/quicktime" : "video/mp4",
|
||||
};
|
||||
}
|
||||
|
||||
function samsungClip(jpeg) {
|
||||
assert.equal(jpeg.subarray(-4).toString(), "SEFT", "sample needs a paired MOV or embedded Samsung clip");
|
||||
const index = jpeg.length - 8 - jpeg.readUInt32LE(jpeg.length - 8);
|
||||
assert.equal(jpeg.subarray(index, index + 4).toString(), "SEFH");
|
||||
const count = jpeg.readUInt32LE(index + 8);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = index + 12 + i * 12;
|
||||
if (jpeg.readUInt16LE(entry + 2) !== 0x0a30) continue;
|
||||
let start = index - jpeg.readUInt32LE(entry + 4) + 24;
|
||||
let length = jpeg.readUInt32LE(entry + 8) - 24;
|
||||
if (jpeg.subarray(start, start + 4).toString() === "mpv2") {
|
||||
[start, length] = [jpeg.readUInt32BE(start + 4), jpeg.readUInt32BE(start + 8)];
|
||||
}
|
||||
assert.ok(start > 0 && length > 0 && start + length <= index);
|
||||
return jpeg.subarray(start, start + length);
|
||||
}
|
||||
throw new Error("No Samsung motion clip found");
|
||||
}
|
||||
|
||||
export function serveMotion(route, { clip, contentType }) {
|
||||
const range = route.request().headers().range?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const end = range?.[2] ? Math.min(Number(range[2]), clip.length - 1) : clip.length - 1;
|
||||
return route.fulfill({ status: range ? 206 : 200, body: clip.subarray(start, end + 1), headers: {
|
||||
"content-type": contentType, "accept-ranges": "bytes",
|
||||
"cache-control": "private, max-age=31536000, immutable", "etag": '"original-motion-v2-fixture"',
|
||||
...(range ? { "content-range": `bytes ${start}-${end}/${clip.length}` } : {}),
|
||||
} });
|
||||
}
|
||||
|
||||
export function imageDimensions(page, poster) {
|
||||
return page.evaluate(async base64 => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/jpeg;base64,${base64}`;
|
||||
await image.decode();
|
||||
return { width: image.naturalWidth, height: image.naturalHeight };
|
||||
}, poster.toString("base64"));
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// Component regression checks: all content is supplied in memory, with no HTTP
|
||||
// listener, backend process, real gallery, or generated playback files.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { engine, launchBrowser, loadSample, serveMotion, imageDimensions } from "./live-photo-fixture.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const sample = await loadSample();
|
||||
const { poster } = sample;
|
||||
const fixture = await build({
|
||||
stdin: { resolveDir: root, loader: "tsx", contents: `
|
||||
import { render } from "preact";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { LivePhoto, stopLivePhotoPlayback } from "./src/LivePhoto";
|
||||
import { ImageViewer } from "./src/ImageViewer";
|
||||
import { useCardInteraction } from "./src/cardInteraction";
|
||||
const images = ["one", "two"].map(id => ({ id, name: id + ".jpg", ...window.fixtureDimensions, motion: "/motion/" + id }));
|
||||
function Fixture() {
|
||||
const [open, setOpen] = useState(null);
|
||||
const gallery = useRef(null), viewer = useRef(null);
|
||||
useCardInteraction(gallery);
|
||||
useCardInteraction(viewer, open !== null);
|
||||
return <>
|
||||
<div ref={gallery} className="gallery" inert={open !== null}>
|
||||
{images.map(image => <div key={image.id} className="image-card" data-loaded="true" data-image-id={image.id}>
|
||||
<img src="/poster.jpg" />
|
||||
<LivePhoto image={image} />
|
||||
<button className="photo-card-open" onClick={() => { stopLivePhotoPlayback(); setOpen(image); }}>open</button>
|
||||
</div>)}
|
||||
</div>
|
||||
{open && (window.fixtureRealViewer ? <ImageViewer images={images} activeIndex={images.indexOf(open)}
|
||||
hasMore={false} loadingMore={false} onNavigate={direction => setOpen(images[images.indexOf(open) + direction])}
|
||||
onClose={() => setOpen(null)} similarActive={false} similarImages={[]} similarTotal={0}
|
||||
similarHasMore={false} similarLoading={false} similarLoadingMore={false} similarError={null}
|
||||
onSearchSimilar={() => {}} onLoadMoreSimilar={() => {}} onOpenImage={setOpen} /> : <div ref={viewer} className="fixture-viewer">
|
||||
<button className="close" onClick={() => setOpen(null)}>close</button>
|
||||
<div className="viewer-media" data-image-id={open.id}><img src="/poster.jpg" /><LivePhoto image={open} autoPlay /></div>
|
||||
</div>)}
|
||||
</>;
|
||||
}
|
||||
render(<Fixture />, document.getElementById("root"));
|
||||
` },
|
||||
bundle: true, write: false, format: "iife", jsx: "automatic", jsxImportSource: "preact",
|
||||
});
|
||||
const css = await readFile(new URL("../src/styles.css", import.meta.url), "utf8");
|
||||
const browser = await launchBrowser();
|
||||
const errors = [];
|
||||
|
||||
async function pageFixture(options = {}) {
|
||||
const { realViewer = false, transientVisibility = false, ...contextOptions } = options;
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, ...contextOptions });
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.route("https://pixhelf.test/**", async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/") return route.fulfill({ contentType: "text/html", body: '<div id="root"></div><script id="pixhelf-auth" type="application/json">{"enabled":false,"authenticated":false}</script>' });
|
||||
if (url.pathname.endsWith("/details")) return route.fulfill({ status: 404, json: {} });
|
||||
if (!url.pathname.startsWith("/motion/")) return route.fulfill({ contentType: "image/jpeg", body: poster });
|
||||
return serveMotion(route, sample);
|
||||
});
|
||||
await page.goto("https://pixhelf.test/", { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate(dimensions => { window.fixtureDimensions = dimensions; }, await imageDimensions(page, poster));
|
||||
await page.addStyleTag({ content: css + `
|
||||
body { margin: 0; min-height: 1800px; }
|
||||
.gallery { display: flex; gap: 12px; padding: 24px; }
|
||||
.image-card { width: 160px; height: 120px; position: relative; }
|
||||
.image-card img, .viewer-media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.photo-card-open { position: absolute; inset: 0; opacity: 0; z-index: 3; }
|
||||
.fixture-viewer { position: fixed; inset: 0; background: white; padding: 50px 20px; }
|
||||
.fixture-viewer .viewer-media { position: relative; width: 320px; height: 240px; }
|
||||
` });
|
||||
await page.evaluate(value => { window.fixtureRealViewer = value; }, realViewer);
|
||||
if (transientVisibility) await page.evaluate(() => {
|
||||
const NativeObserver = IntersectionObserver;
|
||||
window.IntersectionObserver = class extends NativeObserver {
|
||||
constructor(callback, options) { super(callback, options); this.notify = callback; }
|
||||
observe(target) {
|
||||
super.observe(target);
|
||||
if (target.matches(".viewer-media")) queueMicrotask(() => this.notify([{ target, isIntersecting: false }], this));
|
||||
}
|
||||
};
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.metrics = { sources: [], resets: 0, plays: 0, loadStarts: 0 };
|
||||
document.addEventListener("loadstart", event => {
|
||||
if (event.target instanceof HTMLVideoElement) window.metrics.loadStarts++;
|
||||
}, true);
|
||||
const source = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src");
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "src", { ...source, set(value) {
|
||||
window.metrics.sources.push(value); source.set.call(this, value);
|
||||
} });
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
HTMLMediaElement.prototype.load = function () { window.metrics.resets++; return load.call(this); };
|
||||
const play = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
window.metrics.plays++;
|
||||
if (window.rejectNextPlay) {
|
||||
window.rejectNextPlay = false;
|
||||
return Promise.reject(new DOMException("gesture required", "NotAllowedError"));
|
||||
}
|
||||
if (window.abortNextPlay) {
|
||||
window.abortNextPlay = false;
|
||||
return Promise.reject(new DOMException("player moved", "AbortError"));
|
||||
}
|
||||
return play.call(this);
|
||||
};
|
||||
});
|
||||
await page.addScriptTag({ content: fixture.outputFiles[0].text });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function playing(card) {
|
||||
await card.page().waitForFunction(element => {
|
||||
const video = element.querySelector("video");
|
||||
return element.querySelector(".live-photo")?.dataset.playing === "true"
|
||||
&& video?.videoWidth > 0 && video.currentTime > 0 && !video.paused;
|
||||
}, await card.elementHandle());
|
||||
const brightness = await card.locator("video").evaluate(video => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 24;
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(video, 0, 0, 24, 24);
|
||||
const pixels = context.getImageData(0, 0, 24, 24).data;
|
||||
return pixels.reduce((sum, value, i) => sum + (i % 4 === 3 ? 0 : value), 0) / (24 * 24 * 3);
|
||||
});
|
||||
assert.ok(brightness > 10, "the video clock advanced without decoded picture pixels");
|
||||
}
|
||||
|
||||
async function finished(card) {
|
||||
await card.locator("video").evaluate(video => new Promise(resolve => {
|
||||
if (video.ended) resolve();
|
||||
else video.addEventListener("ended", () => resolve(), { once: true });
|
||||
}));
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const real = await pageFixture({ realViewer: true, transientVisibility: width <= 720, viewport: { width, height: 844 }, isMobile: width <= 720, hasTouch: width <= 720 });
|
||||
const card = real.locator('.image-card[data-image-id="one"]');
|
||||
if (width > 720) await card.hover();
|
||||
else await card.dispatchEvent("pixhelf:card-touch");
|
||||
await playing(card);
|
||||
if (width > 720) await real.evaluate(() => { window.abortNextPlay = true; });
|
||||
await card.locator(".photo-card-open").click();
|
||||
const viewer = real.locator(".viewer-media:not(.viewer-swipe-outgoing)");
|
||||
try { await playing(viewer); }
|
||||
catch (error) {
|
||||
console.error(await real.evaluate(() => ({ metrics, layers: [...document.querySelectorAll('.live-photo')].map(layer => ({
|
||||
card: layer.parentElement.className, playing: layer.dataset.playing, failed: layer.dataset.failed,
|
||||
inert: !!layer.closest('[inert]'), connected: layer.isConnected, rect: layer.getBoundingClientRect().toJSON(),
|
||||
video: layer.querySelector('video') && { paused: layer.querySelector('video').paused, time: layer.querySelector('video').currentTime, source: layer.querySelector('video').currentSrc }
|
||||
})) })));
|
||||
throw error;
|
||||
}
|
||||
await viewer.locator("video").evaluate(video => video.pause());
|
||||
await playing(viewer);
|
||||
await viewer.locator("video").evaluate(video => {
|
||||
Object.defineProperty(video, "videoWidth", { configurable: true, value: 0 });
|
||||
Object.defineProperty(video, "videoHeight", { configurable: true, value: 0 });
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
});
|
||||
assert.equal(await viewer.locator(".live-photo").getAttribute("data-failed"), null, "temporary decoder dimensions must not permanently disable playback");
|
||||
await viewer.locator("video").evaluate(video => {
|
||||
delete video.videoWidth;
|
||||
delete video.videoHeight;
|
||||
video.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
await playing(viewer);
|
||||
await real.locator(".viewer-close").click();
|
||||
await real.close();
|
||||
}
|
||||
const page = await pageFixture();
|
||||
const first = page.locator('.image-card[data-image-id="one"]');
|
||||
const second = page.locator('.image-card[data-image-id="two"]');
|
||||
assert.equal(await page.locator("video").count(), 0, "idle cards must not create or load video elements");
|
||||
await first.hover();
|
||||
await playing(first);
|
||||
const before = await page.evaluate(() => {
|
||||
window.originalPlayer = document.querySelector("video");
|
||||
return { resets: metrics.resets, sources: metrics.sources.length, loadStarts: metrics.loadStarts };
|
||||
});
|
||||
await page.mouse.move(380, 400);
|
||||
assert.equal(await first.locator("video").evaluate(video => video.paused), true);
|
||||
const immediate = await first.evaluate(card => {
|
||||
card.dispatchEvent(new Event("pixhelf:card-touch"));
|
||||
return card.querySelector(".live-photo").dataset.playing;
|
||||
});
|
||||
assert.equal(immediate, "true", "a decoded clip should reveal in the touch handler without a timer");
|
||||
assert.deepEqual(await page.evaluate(() => ({ resets: metrics.resets, sources: metrics.sources.length, loadStarts: metrics.loadStarts })), before);
|
||||
await first.locator(".photo-card-open").click();
|
||||
const viewer = page.locator(".viewer-media");
|
||||
await playing(viewer);
|
||||
assert.equal(await page.evaluate(() => window.originalPlayer.paused && !window.originalPlayer.hasAttribute("src")), true,
|
||||
"opening the viewer must release the gallery decoder");
|
||||
assert.equal(await page.locator("video").count(), 1);
|
||||
await page.locator(".close").click();
|
||||
await second.hover();
|
||||
await playing(second);
|
||||
assert.equal(await page.locator("video").count(), 1);
|
||||
await first.dispatchEvent("pointerleave", { pointerType: "mouse" });
|
||||
await playing(second);
|
||||
await second.dispatchEvent("pixhelf:card-menu");
|
||||
assert.equal(await page.locator("video[src]").count(), 0, "menus should release the active decoder");
|
||||
|
||||
const mobile = await pageFixture({ isMobile: true, hasTouch: true });
|
||||
const card = mobile.locator('.image-card[data-image-id="one"]');
|
||||
await card.dispatchEvent("pointerdown", { pointerId: 1, pointerType: "touch", isPrimary: true, button: 0 });
|
||||
assert.equal(await card.getAttribute("data-card-active"), "true");
|
||||
assert.equal(await mobile.locator("video").count(), 0, "raw contact should show feedback without starting playback");
|
||||
await card.dispatchEvent("pointerup", { pointerId: 1, pointerType: "touch", isPrimary: true, button: 0 });
|
||||
await mobile.evaluate(() => { window.rejectNextPlay = true; });
|
||||
await card.locator(".photo-card-open").tap();
|
||||
assert.equal(await mobile.locator(".viewer-media").count(), 0);
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-loading"), "false", "gesture denial should not leave a loading indicator running");
|
||||
const playsBeforeRetry = await mobile.evaluate(() => metrics.plays);
|
||||
await card.locator(".photo-card-open").tap();
|
||||
assert.ok(await mobile.evaluate(() => metrics.plays) > playsBeforeRetry, "a later tap must retry a denied play request");
|
||||
assert.equal(await mobile.locator(".viewer-media").count(), 0);
|
||||
await playing(card);
|
||||
const loading = await card.evaluate(card => {
|
||||
const video = card.querySelector("video"), layer = card.querySelector(".live-photo");
|
||||
// Simulate a stalled download with two buffered ranges totaling half the clip.
|
||||
Object.defineProperties(video, {
|
||||
readyState: { configurable: true, value: 2 },
|
||||
duration: { configurable: true, value: 20 },
|
||||
buffered: { configurable: true, value: { length: 2, start: i => i * 10, end: i => i * 10 + 5 } },
|
||||
});
|
||||
video.dispatchEvent(new Event("waiting"));
|
||||
const feedback = { loading: layer.dataset.loading, value: card.querySelector(".live-photo-loading").getAttribute("aria-valuenow"),
|
||||
progress: layer.style.getPropertyValue("--live-buffer-progress") };
|
||||
delete video.readyState;
|
||||
delete video.duration;
|
||||
delete video.buffered;
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
return { ...feedback, resumed: layer.dataset.loading };
|
||||
});
|
||||
assert.deepEqual(loading, { loading: "true", value: "50", progress: "0.5", resumed: "false" });
|
||||
await mobile.waitForFunction(card => new DOMMatrix(getComputedStyle(card.querySelector("img")).transform).a > 1.02, await card.elementHandle());
|
||||
await card.dispatchEvent("pointerleave", { pointerType: "mouse" });
|
||||
await playing(card);
|
||||
const next = mobile.locator('.image-card[data-image-id="two"]');
|
||||
await next.locator(".photo-card-open").tap();
|
||||
await playing(next);
|
||||
assert.equal(await mobile.locator("video").count(), 1);
|
||||
assert.equal(await card.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
// Unsupported HEVC can expose only an audio track. Never reveal it as a video.
|
||||
await next.locator("video").evaluate(video => {
|
||||
Object.defineProperty(video, "videoWidth", { configurable: true, value: 0 });
|
||||
Object.defineProperty(video, "videoHeight", { configurable: true, value: 0 });
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
});
|
||||
assert.equal(await next.locator(".live-photo").getAttribute("data-playing"), "false");
|
||||
await mobile.waitForFunction(() => document.querySelector('.image-card[data-image-id="two"] .live-photo')?.dataset.failed === "true");
|
||||
assert.equal(await next.locator(".live-photo").getAttribute("data-failed"), "true");
|
||||
assert.equal(await mobile.locator("video[src]").count(), 0);
|
||||
assert.equal(await next.locator(".live-photo").getAttribute("data-loading"), "false");
|
||||
await next.locator(".photo-card-open").tap();
|
||||
await mobile.locator('.viewer-media[data-image-id="two"]').waitFor();
|
||||
|
||||
const sequence = await pageFixture({ isMobile: true, hasTouch: true });
|
||||
const a = sequence.locator('.image-card[data-image-id="one"]');
|
||||
const b = sequence.locator('.image-card[data-image-id="two"]');
|
||||
await a.locator(".photo-card-open").tap();
|
||||
await playing(a);
|
||||
await finished(a);
|
||||
// Contact feedback on B alone is not playback and must not change A's tap.
|
||||
await b.dispatchEvent("pointerdown", { pointerId: 2, pointerType: "touch", isPrimary: true, button: 0 });
|
||||
await b.dispatchEvent("pointerup", { pointerId: 2, pointerType: "touch", isPrimary: true, button: 0 });
|
||||
await a.locator(".photo-card-open").tap();
|
||||
await sequence.locator('.viewer-media[data-image-id="one"]').waitFor();
|
||||
await sequence.locator(".close").click();
|
||||
await b.locator(".photo-card-open").tap();
|
||||
await playing(b);
|
||||
await finished(b);
|
||||
await a.locator(".photo-card-open").tap();
|
||||
assert.equal(await sequence.locator(".viewer-media").count(), 0, "returning after playing B must preview A first");
|
||||
await playing(a);
|
||||
await finished(a);
|
||||
await a.locator(".photo-card-open").tap();
|
||||
await sequence.locator('.viewer-media[data-image-id="one"]').waitFor();
|
||||
await sequence.close();
|
||||
|
||||
// Exercise a decoder that reports buffered frames but stops advancing its
|
||||
// clock. A single pause/resume should recover without resetting the source.
|
||||
const stalled = await pageFixture({ userAgent: "Mozilla/5.0 AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15" });
|
||||
const stalledCard = stalled.locator('.image-card[data-image-id="one"]');
|
||||
await stalledCard.hover();
|
||||
await playing(stalledCard);
|
||||
const beforeStall = await stalled.evaluate(() => ({ ...metrics, sources: [...metrics.sources] }));
|
||||
await stalledCard.locator("video").evaluate(video => {
|
||||
const time = video.currentTime, duration = video.duration;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { configurable: true, get: () => time },
|
||||
readyState: { configurable: true, value: 2 },
|
||||
buffered: { configurable: true, value: { length: 1, start: () => 0, end: () => duration } },
|
||||
});
|
||||
video.dispatchEvent(new Event("waiting"));
|
||||
});
|
||||
await stalled.waitForFunction(plays => metrics.plays > plays, beforeStall.plays);
|
||||
await stalledCard.locator("video").evaluate(video => video.dispatchEvent(new Event("waiting")));
|
||||
await stalled.waitForTimeout(750);
|
||||
const afterStall = await stalled.evaluate(() => ({ ...metrics, sources: [...metrics.sources] }));
|
||||
assert.equal(afterStall.plays, beforeStall.plays + 1, "buffer recovery must not repeatedly restart a stuck decoder");
|
||||
assert.equal(afterStall.resets, beforeStall.resets);
|
||||
assert.deepEqual(afterStall.sources, beforeStall.sources);
|
||||
await stalledCard.locator("video").evaluate(video => {
|
||||
delete video.currentTime;
|
||||
delete video.readyState;
|
||||
delete video.buffered;
|
||||
});
|
||||
await playing(stalledCard);
|
||||
await stalled.close();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`${engine.name()}: ${sample.name} interaction checks passed`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { BookImage, ChevronRight, ImageIcon } from "./icons";
|
||||
import { BookImage, ChevronRight, Folder, ImageIcon } from "./icons";
|
||||
import { formatCount } from "./format";
|
||||
import { galleryHref } from "./galleryLocation";
|
||||
import type { Album } from "./types";
|
||||
@@ -32,7 +32,9 @@ function AlbumCard({ album, onOpen }: { album: Album; onOpen: (path: string) =>
|
||||
|
||||
export function AlbumsView({ albums, search, onOpen }: { albums: Album[] | undefined; search: string; onOpen: (path: string) => void }) {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
const visible = albums?.filter(album => album.path.toLocaleLowerCase().includes(query));
|
||||
const visible = albums?.filter(album => query
|
||||
? album.path.toLocaleLowerCase().includes(query)
|
||||
: !album.path.includes("/"));
|
||||
return (
|
||||
<section className="albums-page" aria-labelledby="albums-title">
|
||||
<header className="albums-heading">
|
||||
@@ -54,6 +56,31 @@ export function AlbumsView({ albums, search, onOpen }: { albums: Album[] | undef
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumChildren({ albums, path, onOpen }: { albums: Album[] | undefined; path: string; onOpen: (path: string) => void }) {
|
||||
const prefix = `${path}/`;
|
||||
const children = albums?.filter(album => album.path.startsWith(prefix)
|
||||
&& !album.path.slice(prefix.length).includes("/"));
|
||||
if (!children?.length) return null;
|
||||
return (
|
||||
<section className="album-children" aria-label="子相册">
|
||||
<h2>子相册 <span>{formatCount(children.length)}</span></h2>
|
||||
<div className="album-folders">
|
||||
{children.map(album => (
|
||||
<a key={album.path} className="album-folder" href={galleryHref("albums", album.path)}
|
||||
onClick={event => followAlbum(event, album.path, onOpen)} data-album-path={album.path}>
|
||||
<span className="album-folder-icon"><Folder size={22} strokeWidth={1.6} /></span>
|
||||
<span className="album-folder-details">
|
||||
<strong title={album.name}>{album.name}</strong>
|
||||
<span>{formatCount(album.count)} 张照片</span>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumHeading({ album, onOpen }: { album: Album; onOpen: (path: string) => void }) {
|
||||
const parts = album.path.split("/");
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "preact/hooks";
|
||||
import { formatCount } from "./format";
|
||||
import { Header, Sidebar } from "./GalleryNavigation";
|
||||
import { AlbumHeading, AlbumsView } from "./AlbumsView";
|
||||
import { AlbumChildren, AlbumHeading, AlbumsView } from "./AlbumsView";
|
||||
import { ExternalStorageView } from "./ExternalStorageView";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { authentication } from "./auth";
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
getDecodedViewerOriginal,
|
||||
prepareViewerImages,
|
||||
} from "./viewerAssets";
|
||||
import { stopLivePhotoPlayback } from "./LivePhoto";
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
const SIMILAR_PAGE_SIZE = 30;
|
||||
@@ -911,6 +912,7 @@ function App() {
|
||||
disposeViewerReturnFlight(viewerReturnFlightRef.current);
|
||||
viewerReturnFlightRef.current = null;
|
||||
viewerAnchorRef.current = captureViewerAnchor(card, pointerY);
|
||||
stopLivePhotoPlayback();
|
||||
prepareViewerImages(images, images.findIndex((image) => image.id === imageId));
|
||||
resetSimilarSearch();
|
||||
setViewerDiscoveredImages([]);
|
||||
@@ -1339,6 +1341,7 @@ function App() {
|
||||
) : (
|
||||
<>
|
||||
{selectedAlbum && <AlbumHeading album={selectedAlbum} onOpen={chooseAlbum} />}
|
||||
{selectedAlbum && <AlbumChildren albums={summary?.albums} path={selectedAlbum.path} onOpen={chooseAlbum} />}
|
||||
{loading ? (
|
||||
<GallerySkeleton />
|
||||
) : images.length ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./icons";
|
||||
import { PhotoInformation } from "./PhotoInformation";
|
||||
import { ImageCardActions } from "./ImageCardActions";
|
||||
import { LIVE_PHOTO_PLAY_EVENT, LivePhoto } from "./LivePhoto";
|
||||
import { CARD_MENU_EVENT, useCardInteraction } from "./cardInteraction";
|
||||
import { useGalleryMetrics } from "./galleryMetrics";
|
||||
import { SimilarImageGallery, SimilarImageSkeleton } from "./SimilarImages";
|
||||
@@ -596,6 +597,7 @@ export function ImageViewer({
|
||||
snapshot.tabIndex = -1;
|
||||
snapshot.removeAttribute("aria-haspopup");
|
||||
snapshot.querySelector(".photo-card-controls")?.remove();
|
||||
snapshot.querySelector(".live-photo")?.remove();
|
||||
snapshot.querySelectorAll<HTMLElement>("[id]").forEach((element) => {
|
||||
element.removeAttribute("id");
|
||||
});
|
||||
@@ -1491,22 +1493,30 @@ export function ImageViewer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchTap = (point: PointerPoint) => {
|
||||
const now = performance.now();
|
||||
const lastTap = lastTapRef.current;
|
||||
if (
|
||||
lastTap &&
|
||||
now - lastTap.at <= DOUBLE_TAP_DELAY_MS &&
|
||||
pointDistance(lastTap.point, point) < 32
|
||||
) {
|
||||
lastTapRef.current = null;
|
||||
zoomAt(
|
||||
transformRef.current.scale > MIN_SCALE ? MIN_SCALE : DOUBLE_TAP_SCALE,
|
||||
point,
|
||||
);
|
||||
return;
|
||||
const handleImageTap = (point: PointerPoint, pointerType: string) => {
|
||||
if (pointerType !== "mouse") {
|
||||
const now = performance.now();
|
||||
const lastTap = lastTapRef.current;
|
||||
if (
|
||||
lastTap &&
|
||||
now - lastTap.at <= DOUBLE_TAP_DELAY_MS &&
|
||||
pointDistance(lastTap.point, point) < 32
|
||||
) {
|
||||
lastTapRef.current = null;
|
||||
zoomAt(
|
||||
transformRef.current.scale > MIN_SCALE ? MIN_SCALE : DOUBLE_TAP_SCALE,
|
||||
point,
|
||||
);
|
||||
return;
|
||||
}
|
||||
lastTapRef.current = { at: now, point };
|
||||
}
|
||||
const media = mediaRef.current;
|
||||
const target = document.elementFromPoint(point.x, point.y);
|
||||
if (image.motion && media && target && media.contains(target)
|
||||
&& !target.closest(".photo-card-controls")) {
|
||||
media.dispatchEvent(new Event(LIVE_PHOTO_PLAY_EVENT));
|
||||
}
|
||||
lastTapRef.current = { at: now, point };
|
||||
};
|
||||
|
||||
const finishContact = (
|
||||
@@ -1555,11 +1565,11 @@ export function ImageViewer({
|
||||
const deltaX = point.x - start.point.x;
|
||||
const deltaY = point.y - start.point.y;
|
||||
const elapsed = Math.max(1, performance.now() - start.startedAt);
|
||||
const isTap = Math.hypot(deltaX, deltaY) < 10 && elapsed < 320;
|
||||
const isTap = !gestureMode && Math.hypot(deltaX, deltaY) < 10 && elapsed < 320;
|
||||
|
||||
if (start.transform.scale > MIN_SCALE) {
|
||||
if (isTap && start.pointerType !== "mouse") {
|
||||
handleTouchTap(point);
|
||||
if (isTap) {
|
||||
handleImageTap(point, start.pointerType);
|
||||
gestureGeometryRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -1623,8 +1633,8 @@ export function ImageViewer({
|
||||
}
|
||||
|
||||
commitTransform(DEFAULT_TRANSFORM);
|
||||
if (isTap && start.pointerType !== "mouse") {
|
||||
handleTouchTap(point);
|
||||
if (isTap) {
|
||||
handleImageTap(point, start.pointerType);
|
||||
}
|
||||
gestureGeometryRef.current = null;
|
||||
};
|
||||
@@ -2043,6 +2053,7 @@ export function ImageViewer({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{image.motion && <LivePhoto image={image} autoPlay />}
|
||||
<ImageCardActions image={image} onAction={action => {
|
||||
if (action === "view") {
|
||||
commitTransform(DEFAULT_TRANSFORM);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { GalleryViewportAnchor } from "./galleryViewport";
|
||||
import { ImageIcon } from "./icons";
|
||||
import { ImageCardActions } from "./ImageCardActions";
|
||||
import { LivePhoto } from "./LivePhoto";
|
||||
import { useGalleryMetrics } from "./galleryMetrics";
|
||||
import { useCardInteraction } from "./cardInteraction";
|
||||
import { layoutJustifiedImages, layoutJustifiedSkeleton } from "./justified";
|
||||
@@ -387,7 +388,8 @@ const ImageCard = memo(function ImageCard({
|
||||
<ImageIcon size={24} />
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" className="photo-card-open" aria-label={`查看 ${image.name}`} aria-haspopup="dialog" />
|
||||
{loaded && image.motion && <LivePhoto image={image} />}
|
||||
<button type="button" className="photo-card-open" aria-label={`查看 ${image.name}${image.motion ? ",live 照片" : ""}`} aria-haspopup="dialog" />
|
||||
<ImageCardActions image={image} onAction={handleAction} />
|
||||
</figure>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { memo } from "preact/compat";
|
||||
import { useLayoutEffect, useRef } from "preact/hooks";
|
||||
import { CARD_MENU_EVENT, CARD_TOUCH_EVENT, clearCardFeedback, markLivePhotoPlayed } from "./cardInteraction";
|
||||
import type { GalleryImage } from "./types";
|
||||
import { viewerThumbnailUrl } from "./viewerAssets";
|
||||
|
||||
type PlaybackInput = "mouse" | "touch" | "keyboard" | "viewer";
|
||||
export const LIVE_PHOTO_PLAY_EVENT = "pixhelf:live-photo-play";
|
||||
const VIEWER_VIDEO_MAX_EDGE = 1440;
|
||||
const VIEWER_VIDEO_FRAME_INTERVAL = 1000 / 30;
|
||||
const IS_WEBKIT = /AppleWebKit\//.test(navigator.userAgent)
|
||||
&& !/(?:Chrome|Chromium|Edg|OPR)\//.test(navigator.userAgent);
|
||||
let stopActivePlayback: ((release?: boolean) => void) | null = null;
|
||||
let player: HTMLVideoElement | null = null;
|
||||
let lifecycleInstalled = false;
|
||||
|
||||
function releasePlayer(): void {
|
||||
if (!player || stopActivePlayback) return;
|
||||
player.pause();
|
||||
if (player.hasAttribute("src")) {
|
||||
player.removeAttribute("src");
|
||||
player.load();
|
||||
}
|
||||
player.remove();
|
||||
player = null;
|
||||
}
|
||||
|
||||
function acquirePlayer(viewport: HTMLDivElement): HTMLVideoElement {
|
||||
// Reuse within a card; moving a decoded surface into the viewer can paint black.
|
||||
if (player && player.parentElement !== viewport) releasePlayer();
|
||||
if (!player) {
|
||||
player = document.createElement("video");
|
||||
player.className = "live-photo-video";
|
||||
player.muted = player.defaultMuted = true;
|
||||
player.setAttribute("muted", "");
|
||||
player.setAttribute("playsinline", "");
|
||||
player.setAttribute("webkit-playsinline", "");
|
||||
player.loop = false;
|
||||
player.preload = "auto";
|
||||
player.disablePictureInPicture = true;
|
||||
player.tabIndex = -1;
|
||||
}
|
||||
if (!lifecycleInstalled) {
|
||||
lifecycleInstalled = true;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) stopLivePhotoPlayback();
|
||||
});
|
||||
window.addEventListener("pagehide", stopLivePhotoPlayback);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
export function stopLivePhotoPlayback(): void {
|
||||
stopActivePlayback?.();
|
||||
releasePlayer();
|
||||
}
|
||||
|
||||
function bufferedPercent(video: HTMLVideoElement | null): number {
|
||||
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return 0;
|
||||
const ranges = video.buffered;
|
||||
let buffered = 0;
|
||||
for (let i = 0; i < ranges.length; i++) buffered += ranges.end(i) - ranges.start(i);
|
||||
return Math.min(100, Math.floor(buffered / video.duration * 100));
|
||||
}
|
||||
|
||||
function createFramePainter(canvas: HTMLCanvasElement | null) {
|
||||
const context = canvas?.getContext("2d", { alpha: false });
|
||||
if (!canvas || !context) {
|
||||
if (canvas) canvas.hidden = true;
|
||||
return null;
|
||||
}
|
||||
let lastPaintAt = -Infinity;
|
||||
let lastPaintTime = -1;
|
||||
return (video: HTMLVideoElement, firstFrame: boolean): boolean => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - lastPaintAt;
|
||||
if (!firstFrame && (elapsed < VIEWER_VIDEO_FRAME_INTERVAL || lastPaintTime === video.currentTime)) return false;
|
||||
const scale = Math.min(1, VIEWER_VIDEO_MAX_EDGE / Math.max(video.videoWidth, video.videoHeight));
|
||||
const width = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
const height = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
// Copy decoded pixels to avoid black native HEVC surfaces in transformed layers.
|
||||
try { context.drawImage(video, 0, 0, width, height); }
|
||||
catch { return false; }
|
||||
lastPaintAt = firstFrame ? now : now - elapsed % VIEWER_VIDEO_FRAME_INTERVAL;
|
||||
lastPaintTime = video.currentTime;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export const LivePhoto = memo(function LivePhoto({ image, autoPlay = false }: { image: GalleryImage; autoPlay?: boolean }) {
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const progressRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
const viewport = viewportRef.current;
|
||||
const card = layer?.parentElement;
|
||||
const source = image.motion;
|
||||
if (!layer || !viewport || !card || !source) return;
|
||||
const sourceUrl = new URL(source, document.baseURI).href;
|
||||
const canvas = canvasRef.current;
|
||||
const paintImage = createFramePainter(canvas);
|
||||
const blocked = () => document.hidden || card.closest("[inert]") || card.querySelector(".photo-card-menu");
|
||||
layer.title = "live 照片";
|
||||
delete layer.dataset.failed;
|
||||
let video: HTMLVideoElement | null = null;
|
||||
let active = false;
|
||||
let completed = false;
|
||||
let failed = false;
|
||||
let generation = 0;
|
||||
let videoFrame = 0;
|
||||
let paintFrame = 0;
|
||||
let resumeFrame = 0;
|
||||
let dimensionTimer = 0;
|
||||
let bufferResumeTimer = 0;
|
||||
let bufferResumeUsed = false;
|
||||
let resumeAttempts = 0;
|
||||
let playPending = false;
|
||||
let waiting = false;
|
||||
let waitingAt = 0;
|
||||
let input: PlaybackInput = "mouse";
|
||||
let playbackListeners: AbortController | null = null;
|
||||
let visibility: IntersectionObserver | null = null;
|
||||
let viewerVisibility: IntersectionObserver | null = null;
|
||||
const updateLoadingProgress = () => {
|
||||
if (!active || layer.dataset.loading !== "true") return;
|
||||
const percent = bufferedPercent(video);
|
||||
layer.dataset.bufferKnown = String(percent > 0);
|
||||
layer.style.setProperty("--live-buffer-progress", String(percent / 100));
|
||||
if (percent > 0) progressRef.current?.setAttribute("aria-valuenow", String(percent));
|
||||
else progressRef.current?.removeAttribute("aria-valuenow");
|
||||
};
|
||||
const setLoading = (loading: boolean) => {
|
||||
if (layer.dataset.loading === String(loading)) return;
|
||||
layer.dataset.loading = String(loading);
|
||||
if (loading) updateLoadingProgress();
|
||||
};
|
||||
const stop = (release = true) => {
|
||||
generation++;
|
||||
active = false;
|
||||
playPending = false;
|
||||
waiting = false;
|
||||
setLoading(false);
|
||||
playbackListeners?.abort();
|
||||
playbackListeners = null;
|
||||
visibility?.disconnect();
|
||||
visibility = null;
|
||||
if (videoFrame) video?.cancelVideoFrameCallback?.(videoFrame);
|
||||
videoFrame = 0;
|
||||
cancelAnimationFrame(paintFrame);
|
||||
paintFrame = 0;
|
||||
cancelAnimationFrame(resumeFrame);
|
||||
resumeFrame = 0;
|
||||
window.clearTimeout(dimensionTimer);
|
||||
dimensionTimer = 0;
|
||||
window.clearTimeout(bufferResumeTimer);
|
||||
bufferResumeTimer = 0;
|
||||
clearCardFeedback(card);
|
||||
layer.dataset.playing = "false";
|
||||
// Cleanup may release this card's completed player, never another card's.
|
||||
if (stopActivePlayback === stop) {
|
||||
stopActivePlayback = null;
|
||||
video?.pause();
|
||||
}
|
||||
if (release && !stopActivePlayback && player?.parentElement === viewport) releasePlayer();
|
||||
};
|
||||
const fail = () => {
|
||||
if (!active) return;
|
||||
failed = true;
|
||||
layer.title = "当前浏览器无法播放此 live,仍可查看照片";
|
||||
layer.dataset.failed = "true";
|
||||
stop();
|
||||
};
|
||||
const play = () => {
|
||||
if (playPending || !active || !video) return;
|
||||
const attempt = generation;
|
||||
playPending = true;
|
||||
setLoading(true);
|
||||
void video.play().then(() => {
|
||||
if (generation === attempt) playPending = false;
|
||||
}).catch((error: unknown) => {
|
||||
if (!active || generation !== attempt || stopActivePlayback !== stop) return;
|
||||
playPending = false;
|
||||
// A tap can retry in browsers requiring a fresh user gesture.
|
||||
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
||||
waiting = false;
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Visibility and source changes can asynchronously interrupt play().
|
||||
if (error instanceof DOMException && error.name === "AbortError") { resume(); return; }
|
||||
if (error instanceof DOMException && error.name === "NotSupportedError") fail();
|
||||
else stop();
|
||||
});
|
||||
};
|
||||
const resume = () => {
|
||||
if (!active || video?.ended || resumeFrame || resumeAttempts >= 2 || document.hidden) return;
|
||||
const attempt = generation;
|
||||
resumeFrame = requestAnimationFrame(() => {
|
||||
resumeFrame = 0;
|
||||
if (!active || generation !== attempt || !video?.paused || video.ended || !card.isConnected || blocked()) return;
|
||||
resumeAttempts++;
|
||||
play();
|
||||
});
|
||||
};
|
||||
const showFrame = () => {
|
||||
if (!active || !video || video.paused || video.seeking || video.currentSrc !== sourceUrl
|
||||
|| video.videoWidth === 0 || video.videoHeight === 0 || video.readyState < 2) return;
|
||||
if (paintImage && !paintImage(video, layer.dataset.playing !== "true")) return;
|
||||
window.clearTimeout(dimensionTimer);
|
||||
dimensionTimer = 0;
|
||||
resumeAttempts = 0;
|
||||
if (layer.dataset.playing !== "true") {
|
||||
layer.dataset.playing = "true";
|
||||
markLivePhotoPlayed(image.id);
|
||||
}
|
||||
// WebKit can keep HAVE_CURRENT_DATA during a fully buffered replay. A
|
||||
// drawable frame with an advancing clock also means playback resumed.
|
||||
if (video.readyState >= 3 || Math.abs(video.currentTime - waitingAt) > 0.03) waiting = false;
|
||||
if (!waiting) setLoading(false);
|
||||
};
|
||||
const paint = () => {
|
||||
paintFrame = 0;
|
||||
if (!active) return;
|
||||
showFrame();
|
||||
// Compositor frame callbacks can stop when the canvas covers the video.
|
||||
// This loop copies at most 30 frames/s, without CPU pixel reads or caches.
|
||||
paintFrame = requestAnimationFrame(paint);
|
||||
};
|
||||
const loaded = () => {
|
||||
showFrame();
|
||||
if (!active || !video || video.currentSrc !== sourceUrl || dimensionTimer
|
||||
|| (video.videoWidth > 0 && video.videoHeight > 0)) return;
|
||||
layer.dataset.playing = "false";
|
||||
const attempt = generation;
|
||||
// Some decoders expose audio before their first video dimensions. Allow
|
||||
// resize/loadeddata to supply the frame before treating it as audio-only.
|
||||
dimensionTimer = window.setTimeout(() => {
|
||||
dimensionTimer = 0;
|
||||
if (active && generation === attempt && video?.currentSrc === sourceUrl
|
||||
&& video.readyState >= 2 && (video.videoWidth === 0 || video.videoHeight === 0)) fail();
|
||||
}, 1000);
|
||||
};
|
||||
const watchBufferedPlayback = () => {
|
||||
if (!IS_WEBKIT || bufferResumeUsed || !active || !video) return;
|
||||
window.clearTimeout(bufferResumeTimer);
|
||||
const attempt = generation;
|
||||
const stalledAt = video.currentTime;
|
||||
bufferResumeTimer = window.setTimeout(() => {
|
||||
bufferResumeTimer = 0;
|
||||
if (!active || generation !== attempt || !video || video.paused || video.ended
|
||||
|| video.seeking || video.videoWidth === 0 || Math.abs(video.currentTime - stalledAt) > 0.03) return;
|
||||
// WebKit can stop its clock after announcing playing, even with decoded
|
||||
// frames buffered. Resume once, without seeking, replacing src or load().
|
||||
for (let i = 0; i < video.buffered.length; i++) {
|
||||
if (video.buffered.start(i) <= video.currentTime
|
||||
&& video.buffered.end(i) > video.currentTime + 0.15) {
|
||||
bufferResumeUsed = true;
|
||||
video.pause();
|
||||
resume();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 600);
|
||||
};
|
||||
const playing = () => {
|
||||
if (!active || !video) return;
|
||||
watchBufferedPlayback();
|
||||
waiting = false;
|
||||
showFrame();
|
||||
// loadeddata may have already drawn this frame before playing is emitted.
|
||||
if (layer.dataset.playing === "true") setLoading(false);
|
||||
// Keep a decoder frame callback for the first presented frame.
|
||||
if (video.requestVideoFrameCallback && !videoFrame) {
|
||||
videoFrame = video.requestVideoFrameCallback(() => {
|
||||
videoFrame = 0;
|
||||
showFrame();
|
||||
});
|
||||
}
|
||||
};
|
||||
const buffering = () => {
|
||||
if (!active || !video || video.ended) return;
|
||||
watchBufferedPlayback();
|
||||
if (!video.seeking && video.readyState >= 3) return;
|
||||
waiting = true;
|
||||
waitingAt = video.currentTime;
|
||||
setLoading(true);
|
||||
updateLoadingProgress();
|
||||
};
|
||||
const start = (nextInput: PlaybackInput) => {
|
||||
if (autoPlay) nextInput = "viewer";
|
||||
if (failed || blocked()) return;
|
||||
if (active) {
|
||||
resumeAttempts = 0;
|
||||
if (nextInput === "touch" || nextInput === "viewer") input = nextInput;
|
||||
if (video?.paused) play();
|
||||
return;
|
||||
}
|
||||
stopActivePlayback?.(false);
|
||||
video = acquirePlayer(viewport);
|
||||
stopActivePlayback = stop;
|
||||
active = true;
|
||||
completed = false;
|
||||
bufferResumeUsed = false;
|
||||
resumeAttempts = 0;
|
||||
input = nextInput;
|
||||
const attempt = ++generation;
|
||||
playbackListeners = new AbortController();
|
||||
const options = { capture: true, passive: true, signal: playbackListeners.signal };
|
||||
const stopHover = () => { if (input === "mouse" || input === "keyboard") stop(); };
|
||||
document.addEventListener("scroll", stopHover, options);
|
||||
window.addEventListener("blur", event => { if (event.target === window) stop(); }, options);
|
||||
document.addEventListener("pointerdown", event => {
|
||||
if (event.pointerType === "mouse" && event.target instanceof Node && !card.contains(event.target)) stop();
|
||||
}, options);
|
||||
if (!autoPlay) {
|
||||
visibility = new IntersectionObserver(entries => {
|
||||
if (active && generation === attempt && entries.some(entry => !entry.isIntersecting)) stop();
|
||||
});
|
||||
visibility.observe(card);
|
||||
}
|
||||
video.addEventListener("playing", playing, options);
|
||||
for (const event of ["waiting", "stalled", "seeking"]) video.addEventListener(event, buffering, options);
|
||||
for (const event of ["progress", "loadedmetadata"]) video.addEventListener(event, updateLoadingProgress, options);
|
||||
for (const event of ["loadeddata", "seeked", "resize"]) video.addEventListener(event, loaded, options);
|
||||
video.addEventListener("pause", resume, options);
|
||||
video.addEventListener("timeupdate", showFrame, options);
|
||||
video.addEventListener("ended", () => {
|
||||
completed = true;
|
||||
// WebKit needs a fresh decoder to retain QuickTime rotation on replay.
|
||||
stop(IS_WEBKIT);
|
||||
}, options);
|
||||
video.addEventListener("error", fail, options);
|
||||
if (!video.muted) video.muted = true;
|
||||
const poster = viewerThumbnailUrl(image);
|
||||
if (video.getAttribute("poster") !== poster) video.poster = poster;
|
||||
if (video.parentElement !== viewport) viewport.appendChild(video);
|
||||
// Only a repeat interaction within this same card keeps its source.
|
||||
if (video.getAttribute("src") !== source) video.src = source;
|
||||
play();
|
||||
showFrame();
|
||||
if (paintImage && !paintFrame) paintFrame = requestAnimationFrame(paint);
|
||||
};
|
||||
const listeners = new AbortController();
|
||||
const options = { passive: true, signal: listeners.signal };
|
||||
if (autoPlay) {
|
||||
// The viewer dispatches this only for a tap, excluding drags and pinches.
|
||||
card.addEventListener(LIVE_PHOTO_PLAY_EVENT, () => start("viewer"), options);
|
||||
} else {
|
||||
card.addEventListener("pointerenter", event => { if (event.pointerType === "mouse") start("mouse"); }, options);
|
||||
card.addEventListener("pointerleave", event => {
|
||||
if (event.pointerType === "mouse" && input === "mouse") stop(false);
|
||||
}, options);
|
||||
card.addEventListener(CARD_TOUCH_EVENT, () => start("touch"), options);
|
||||
card.addEventListener("click", () => { if (active && video?.paused) play(); }, options);
|
||||
card.addEventListener("focusin", event => {
|
||||
if (event.target instanceof Element && event.target.matches(".photo-card-open:focus-visible")) start("keyboard");
|
||||
}, options);
|
||||
card.addEventListener("focusout", () => { if (input === "keyboard") stop(); }, options);
|
||||
}
|
||||
card.addEventListener(CARD_MENU_EVENT, () => stop(), options);
|
||||
if (autoPlay) {
|
||||
let wasVisible = false;
|
||||
// Resume interrupted playback after a temporary non-visible layout, but
|
||||
// leave a completed clip stopped until the user taps the photo.
|
||||
viewerVisibility = new IntersectionObserver(entries => {
|
||||
const entry = entries.at(-1);
|
||||
if (!entry) return;
|
||||
if (entry.isIntersecting) {
|
||||
wasVisible = true;
|
||||
if (!completed) start("viewer");
|
||||
}
|
||||
else if (!completed) stop(wasVisible);
|
||||
});
|
||||
viewerVisibility.observe(card);
|
||||
start("viewer"); // Keep autoplay in the mounting/user-gesture call stack.
|
||||
} else if (window.matchMedia("(hover: hover) and (pointer: fine)").matches && card.matches(":hover")) start("mouse");
|
||||
return () => {
|
||||
listeners.abort();
|
||||
viewerVisibility?.disconnect();
|
||||
stop();
|
||||
if (canvas) canvas.width = canvas.height = 1;
|
||||
};
|
||||
}, [image.id, image.motion, autoPlay]);
|
||||
|
||||
if (!image.motion) return null;
|
||||
return (
|
||||
<div ref={layerRef} className="live-photo" data-playing="false" data-loading="false" title="live 照片">
|
||||
<div ref={viewportRef} className="live-photo-viewport" aria-hidden="true">
|
||||
{autoPlay && <canvas ref={canvasRef} className="live-photo-canvas" width={1} height={1} />}
|
||||
</div>
|
||||
<span className="live-photo-badge" aria-hidden="true">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeDasharray="1.4 2" />
|
||||
<circle cx="8" cy="8" r="3.5" stroke="currentColor" />
|
||||
<circle cx="8" cy="8" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
live
|
||||
</span>
|
||||
<span ref={progressRef} className="live-photo-loading" role="progressbar" aria-label="加载 live" aria-valuemin={0} aria-valuemax={100}>
|
||||
<span className="live-photo-progress" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
|
||||
import { ImageIcon, LoaderCircle } from "./icons";
|
||||
import { ImageCardActions } from "./ImageCardActions";
|
||||
import { LivePhoto } from "./LivePhoto";
|
||||
import { layoutJustifiedImages, layoutJustifiedSkeleton } from "./justified";
|
||||
import type { GalleryMetrics } from "./galleryMetrics";
|
||||
import { useCardInteraction } from "./cardInteraction";
|
||||
@@ -68,7 +69,8 @@ const SimilarImageCard = memo(function SimilarImageCard({
|
||||
<ImageIcon size={24} />
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="photo-card-open" aria-label={`查看相似图片 ${image.name}`} aria-haspopup="dialog" />
|
||||
{loaded && image.motion && <LivePhoto image={image} />}
|
||||
<button type="button" className="photo-card-open" aria-label={`查看相似图片 ${image.name}${image.motion ? ",live 照片" : ""}`} aria-haspopup="dialog" />
|
||||
<ImageCardActions image={image} onAction={handleAction} />
|
||||
</figure>
|
||||
);
|
||||
|
||||
+14
-1
@@ -1,6 +1,18 @@
|
||||
.albums-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.albums-heading h1 { min-width: 0; margin: 0; color: var(--ink); font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -.4px; overflow-wrap: anywhere; }
|
||||
.albums-heading > span, .album-heading > span { flex: none; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.album-children { margin-bottom: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--border); }
|
||||
.album-children > h2 { display: flex; align-items: baseline; gap: 7px; margin: 0 0 10px; color: var(--ink); font-size: 12px; font-weight: 600; }
|
||||
.album-children > h2 > span { color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||
.album-folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr)); gap: 8px; }
|
||||
.album-folder { display: flex; min-width: 0; align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid #dde5df; border-radius: 10px; color: var(--ink); background: #f9fbf9; text-decoration: none; transition: background var(--motion-fast), border-color var(--motion-fast); }
|
||||
.album-folder-icon { display: grid; flex: none; place-items: center; width: 38px; height: 38px; border-radius: 9px; color: #5b8066; background: #e8f0e9; }
|
||||
.album-folder-details { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.album-folder-details > strong { overflow: hidden; font-size: 13px; font-weight: 600; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.album-folder-details > span { color: var(--muted); font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
||||
.album-folder > svg { flex: none; color: var(--muted); }
|
||||
.album-folder:focus-visible { outline-offset: 3px; }
|
||||
.album-folder:active { background: #eaf1ec; }
|
||||
.albums-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 14px 12px; padding-bottom: 12px; }
|
||||
.album-card { min-width: 0; color: inherit; text-decoration: none; border-radius: 12px; }
|
||||
.album-cover { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; color: #89a398; background: linear-gradient(135deg, #e4eee8, #d9e5df); border: 1px solid #d4e0d8; border-radius: 12px; }
|
||||
@@ -21,6 +33,7 @@
|
||||
.album-breadcrumbs a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.albums-skeleton > div { aspect-ratio: 4 / 3; border-radius: 12px; background: #e8eeea; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.album-folder:hover { background: #eef4ef; border-color: #bccfc0; }
|
||||
.album-card:hover .album-cover > img { transform: scale(1.035); }
|
||||
.album-card:hover h2 { color: var(--accent); }
|
||||
}
|
||||
@@ -31,5 +44,5 @@
|
||||
.album-size { right: 7px; bottom: 7px; padding: 4px 6px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.album-cover > img { transition: none; }
|
||||
.album-cover > img, .album-folder { transition: none; }
|
||||
}
|
||||
@@ -42,6 +42,7 @@ function isGalleryImage(value: unknown): value is GalleryImage {
|
||||
return isObject(value)
|
||||
&& typeof value.id === "string"
|
||||
&& typeof value.name === "string"
|
||||
&& (value.motion === undefined || typeof value.motion === "string")
|
||||
&& isPositiveInteger(value.width)
|
||||
&& isPositiveInteger(value.height);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,36 @@ import { useLayoutEffect } from "preact/hooks";
|
||||
|
||||
export const CARD_SELECTOR = ".image-card, .viewer-similar-card, .viewer-media";
|
||||
export const CARD_MENU_EVENT = "pixhelf:card-menu";
|
||||
export const CARD_TOUCH_EVENT = "pixhelf:card-touch";
|
||||
export type CardMenuRequest = { touch: boolean; point?: { x: number; y: number } };
|
||||
|
||||
let feedbackCard: HTMLElement | null = null;
|
||||
let feedbackVisibility: IntersectionObserver | null = null;
|
||||
let lastPlayedLivePhoto: string | null = null;
|
||||
|
||||
export function markLivePhotoPlayed(imageId: string): void {
|
||||
lastPlayedLivePhoto = imageId;
|
||||
}
|
||||
|
||||
export function clearCardFeedback(card?: HTMLElement): void {
|
||||
if (card && card !== feedbackCard) return;
|
||||
if (feedbackCard) delete feedbackCard.dataset.cardActive;
|
||||
feedbackCard = null;
|
||||
feedbackVisibility?.disconnect();
|
||||
feedbackVisibility = null;
|
||||
}
|
||||
|
||||
function activateCardFeedback(card: HTMLElement): void {
|
||||
if (feedbackCard === card) return;
|
||||
clearCardFeedback();
|
||||
feedbackCard = card;
|
||||
card.dataset.cardActive = "true";
|
||||
feedbackVisibility = new IntersectionObserver(entries => {
|
||||
if (feedbackCard === card && entries.some(entry => !entry.isIntersecting)) clearCardFeedback(card);
|
||||
});
|
||||
feedbackVisibility.observe(card);
|
||||
}
|
||||
|
||||
export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = true) {
|
||||
useLayoutEffect(() => {
|
||||
const gallery = ref.current;
|
||||
@@ -12,11 +40,17 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
let timer = 0;
|
||||
let contact: { card: HTMLElement; pointerId: number; x: number; y: number } | null = null;
|
||||
let consumed: { card: HTMLElement; pointerId?: number } | null = null;
|
||||
let finger: { x: number; y: number } | null = null;
|
||||
let touched: HTMLElement | null = null;
|
||||
let touchFrame = 0;
|
||||
let touchClick = false;
|
||||
const input = (type: string) => {
|
||||
if (gallery.dataset.cardInput !== type) gallery.dataset.cardInput = type;
|
||||
};
|
||||
const fromTouch = (event: MouseEvent) => event instanceof PointerEvent && event.pointerType
|
||||
? event.pointerType !== "mouse" : gallery.dataset.cardInput === "touch";
|
||||
const cardAt = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Element) || target.closest(".photo-card-controls")) return null;
|
||||
if (!(target instanceof Element) || target.closest(".photo-card-controls, [inert]")) return null;
|
||||
const card = target.closest<HTMLElement>(CARD_SELECTOR);
|
||||
return card && gallery.contains(card) ? card : null;
|
||||
};
|
||||
@@ -26,10 +60,34 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
contact = null;
|
||||
};
|
||||
const show = (card: HTMLElement, request: CardMenuRequest, pointerId?: number) => {
|
||||
clearCardFeedback(card);
|
||||
if (request.touch) consumed = { card, pointerId };
|
||||
card.dispatchEvent(new CustomEvent(CARD_MENU_EVENT, { detail: request }));
|
||||
};
|
||||
const touchCard = (card: HTMLElement | null) => {
|
||||
if (!card || card.querySelector(".photo-card-menu")) return;
|
||||
touched = card;
|
||||
activateCardFeedback(card);
|
||||
};
|
||||
const touchAtFinger = () => {
|
||||
touchFrame = 0;
|
||||
if (finger) touchCard(cardAt(document.elementFromPoint(finger.x, finger.y)));
|
||||
};
|
||||
const trackTouch = (event: TouchEvent) => {
|
||||
if (event.type === "touchstart") touchClick = true;
|
||||
if (event.touches.length !== 1) { finger = null; return; }
|
||||
const touch = event.touches[0]!;
|
||||
finger = { x: touch.clientX, y: touch.clientY };
|
||||
touchAtFinger();
|
||||
};
|
||||
const finishTouch = () => {
|
||||
// Keep the feedback after release; playback is triggered by the tap below.
|
||||
if (touched?.isConnected && !touched.closest("[inert]")) touchCard(touched);
|
||||
finger = null;
|
||||
touched = null;
|
||||
};
|
||||
const down = (event: PointerEvent) => {
|
||||
touchClick = event.pointerType !== "mouse";
|
||||
input(event.pointerType === "mouse" ? "mouse" : "touch");
|
||||
// A new contact can select a menu item; only the opening gesture is consumed.
|
||||
consumed = null;
|
||||
@@ -37,6 +95,7 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
if (event.pointerType === "mouse" || !event.isPrimary || event.button !== 0) return;
|
||||
const card = cardAt(event.target);
|
||||
if (!card) return;
|
||||
touchCard(card);
|
||||
contact = { card, pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
timer = window.setTimeout(() => {
|
||||
if (!contact || !card.isConnected) return;
|
||||
@@ -57,11 +116,9 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
const card = cardAt(event.target);
|
||||
if (!card) return;
|
||||
event.preventDefault();
|
||||
const touch = event instanceof PointerEvent && event.pointerType
|
||||
? event.pointerType !== "mouse" : gallery.dataset.cardInput === "touch";
|
||||
const pointerId = contact?.pointerId;
|
||||
cancel();
|
||||
show(card, { touch, point: event.clientX || event.clientY ? { x: event.clientX, y: event.clientY } : undefined }, pointerId);
|
||||
show(card, { touch: fromTouch(event), point: event.clientX || event.clientY ? { x: event.clientX, y: event.clientY } : undefined }, pointerId);
|
||||
};
|
||||
const followsLongPress = (event: MouseEvent) => consumed && event.detail > 0
|
||||
&& ((event.target instanceof Node && consumed.card.contains(event.target))
|
||||
@@ -71,17 +128,39 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
if (followsLongPress(event)) event.preventDefault();
|
||||
};
|
||||
const click = (event: MouseEvent) => {
|
||||
if (!followsLongPress(event)) return;
|
||||
consumed = null;
|
||||
if (followsLongPress(event)) {
|
||||
consumed = null;
|
||||
touchClick = false;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
// WebKit labels the compatibility click after a touch as pointerType=mouse.
|
||||
// Keep the input type from the gesture's start until this click is consumed.
|
||||
const touch = touchClick || fromTouch(event);
|
||||
touchClick = false;
|
||||
if (!touch || event.detail === 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
||||
const card = cardAt(event.target);
|
||||
if (!card || card.matches(".viewer-media")) return;
|
||||
const live = card.querySelector<HTMLElement>(".live-photo");
|
||||
if (!live || live.dataset.failed === "true"
|
||||
|| live.dataset.playing === "true" || live.dataset.loading === "true"
|
||||
|| card.dataset.imageId === lastPlayedLivePhoto) return;
|
||||
// The most recently played photo opens even after playback ends. Once a
|
||||
// different live photo plays, this photo needs a playback tap again.
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
activateCardFeedback(card);
|
||||
card.dispatchEvent(new Event(CARD_TOUCH_EVENT));
|
||||
};
|
||||
const touchMove = (event: TouchEvent) => {
|
||||
if (consumed && contact && event.cancelable) event.preventDefault();
|
||||
trackTouch(event);
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
input("keyboard");
|
||||
touchClick = false;
|
||||
consumed = null;
|
||||
cancel();
|
||||
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
||||
@@ -100,13 +179,21 @@ export function useCardInteraction(ref: RefObject<HTMLDivElement>, enabled = tru
|
||||
document.addEventListener("contextmenu", context, capture);
|
||||
document.addEventListener("mousedown", mouseDown, capture);
|
||||
document.addEventListener("click", click, capture);
|
||||
document.addEventListener("touchstart", trackTouch, { ...capture, passive: true });
|
||||
document.addEventListener("touchmove", touchMove, { ...capture, passive: false });
|
||||
document.addEventListener("touchend", finishTouch, { ...capture, passive: true });
|
||||
document.addEventListener("touchcancel", () => { finger = null; touched = null; }, { ...capture, passive: true });
|
||||
document.addEventListener("keydown", key, capture);
|
||||
document.addEventListener("scroll", cancel, capture);
|
||||
document.addEventListener("visibilitychange", cancel, { signal });
|
||||
document.addEventListener("scroll", () => {
|
||||
cancel();
|
||||
if (finger && !touchFrame) touchFrame = requestAnimationFrame(touchAtFinger);
|
||||
}, capture);
|
||||
document.addEventListener("visibilitychange", () => { cancel(); if (document.hidden) clearCardFeedback(); }, { signal });
|
||||
window.addEventListener("blur", cancel, { signal });
|
||||
return () => {
|
||||
cancel();
|
||||
cancelAnimationFrame(touchFrame);
|
||||
if (feedbackCard && (gallery.contains(feedbackCard) || !feedbackCard.isConnected)) clearCardFeedback();
|
||||
listeners.abort();
|
||||
};
|
||||
}, [enabled, ref]);
|
||||
|
||||
+146
-4
@@ -811,7 +811,7 @@ select:focus-visible,
|
||||
transform: scale(1.006);
|
||||
transition:
|
||||
opacity var(--motion-medium) ease,
|
||||
transform var(--motion-slow) var(--ease-standard),
|
||||
transform var(--motion-medium) var(--ease-standard),
|
||||
filter var(--motion-medium) ease;
|
||||
}
|
||||
|
||||
@@ -824,7 +824,7 @@ select:focus-visible,
|
||||
.image-card[data-high-priority="true"] img {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: none;
|
||||
transition: transform var(--motion-medium) var(--ease-standard);
|
||||
}
|
||||
|
||||
.image-fallback {
|
||||
@@ -837,6 +837,127 @@ select:focus-visible,
|
||||
animation: feedback-in var(--motion-medium) ease-out both;
|
||||
}
|
||||
|
||||
.live-photo {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-media > .live-photo { z-index: 4; }
|
||||
|
||||
.live-photo-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.live-photo[data-playing="true"] .live-photo-viewport {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.live-photo-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
transition: transform var(--motion-medium) var(--ease-standard);
|
||||
}
|
||||
|
||||
.viewer-media .live-photo-canvas {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.viewer-media .live-photo-canvas[hidden] { display: none; }
|
||||
|
||||
.viewer-media .live-photo-canvas:not([hidden]) ~ .live-photo-video { opacity: 0; }
|
||||
|
||||
.live-photo-badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
background: rgba(16, 22, 19, .55);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.live-photo[data-failed="true"] .live-photo-badge::after { content: "· 暂不可播放"; }
|
||||
|
||||
.live-photo-loading {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, .3);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.viewer-media .live-photo-loading { width: 64px; bottom: 16px; }
|
||||
|
||||
.live-photo[data-loading="true"] .live-photo-loading {
|
||||
display: block;
|
||||
animation: live-loading-in 140ms ease-out 120ms both;
|
||||
}
|
||||
|
||||
.live-photo-progress {
|
||||
display: block;
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
animation: live-loading-progress 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.live-photo[data-buffer-known="true"] .live-photo-progress {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
transform-origin: left;
|
||||
transform: scaleX(var(--live-buffer-progress, 0));
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes live-loading-progress {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
@keyframes live-loading-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.image-card[data-card-active="true"],
|
||||
.viewer-similar-card[data-card-active="true"] {
|
||||
z-index: 2;
|
||||
box-shadow: 0 10px 24px rgba(17, 32, 27, .22);
|
||||
}
|
||||
|
||||
.image-card[data-card-active="true"][data-loaded="true"] img,
|
||||
.viewer-similar-card[data-card-active="true"][data-loaded="true"] img,
|
||||
.image-card[data-card-active="true"] .live-photo-video,
|
||||
.viewer-similar-card[data-card-active="true"] .live-photo-video {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
@keyframes card-shimmer {
|
||||
from { transform: translate3d(-100%, 0, 0); }
|
||||
to { transform: translate3d(100%, 0, 0); }
|
||||
@@ -1559,8 +1680,15 @@ select:focus-visible,
|
||||
|
||||
.image-card:hover,
|
||||
.viewer-similar-card:hover {
|
||||
border-color: rgba(24, 82, 74, .2);
|
||||
box-shadow: 0 7px 20px rgba(17, 32, 27, .13);
|
||||
z-index: 2;
|
||||
box-shadow: 0 10px 24px rgba(17, 32, 27, .22);
|
||||
}
|
||||
|
||||
.image-card[data-loaded="true"]:hover img,
|
||||
.viewer-similar-card[data-loaded="true"]:hover img,
|
||||
.image-card:hover .live-photo-video,
|
||||
.viewer-similar-card:hover .live-photo-video {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1768,6 +1896,13 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-card[data-card-active="true"] img,
|
||||
.viewer-similar-card[data-card-active="true"] img,
|
||||
.image-card[data-card-active="true"] .live-photo-video,
|
||||
.viewer-similar-card[data-card-active="true"] .live-photo-video {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -1779,6 +1914,13 @@ select:focus-visible,
|
||||
transition-delay: 0.01ms !important;
|
||||
}
|
||||
|
||||
.image-card[data-loaded="true"]:hover img,
|
||||
.viewer-similar-card[data-loaded="true"]:hover img,
|
||||
.image-card:hover .live-photo-video,
|
||||
.viewer-similar-card:hover .live-photo-video {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.request-progress span {
|
||||
background: var(--accent);
|
||||
transform: none;
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface GalleryImage {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
motion?: string;
|
||||
}
|
||||
|
||||
export type ImageCardAction = "view" | "details" | "similar";
|
||||
|
||||
+36
-9
@@ -197,12 +197,23 @@ async fn fixture() -> (TempDir, Router, String) {
|
||||
}
|
||||
|
||||
async fn fixture_with_config(auth: AuthConfig) -> (TempDir, Router, String) {
|
||||
fixture_with_motion(auth, false).await
|
||||
}
|
||||
|
||||
async fn fixture_with_motion(auth: AuthConfig, live: bool) -> (TempDir, Router, String) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::from_pixel(40, 30, image::Rgb([220, 80, 40]))
|
||||
.save(gallery.join("private-photo.png"))
|
||||
.unwrap();
|
||||
if live {
|
||||
std::fs::write(
|
||||
gallery.join("private-photo.mp4"),
|
||||
crate::motion::tests::video_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
let id = index.images[0].id.clone();
|
||||
let thumbnails = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
@@ -288,6 +299,7 @@ async fn anonymous_requests_cannot_access_bootstrap_apis_or_conditional_media()
|
||||
format!("/api/images/{id}/similar"),
|
||||
format!("/api/images/{id}/thumbnail"),
|
||||
format!("/api/images/{id}/original"),
|
||||
format!("/api/images/{id}/motion/original/missing"),
|
||||
"/not-a-public-route".into(),
|
||||
] {
|
||||
for method in [Method::GET, Method::HEAD] {
|
||||
@@ -630,7 +642,7 @@ async fn an_explicit_public_url_still_restricts_login_addresses() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_session_protects_cached_and_range_responses_until_csrf_checked_logout() {
|
||||
let (_temp, app, id) = fixture().await;
|
||||
let (_temp, app, id) = fixture_with_motion(config(), true).await;
|
||||
let login = sign_in(&app, None).await;
|
||||
assert_eq!(login.status(), StatusCode::OK);
|
||||
let cookie = session_cookie(&login);
|
||||
@@ -645,6 +657,16 @@ async fn valid_session_protects_cached_and_range_responses_until_csrf_checked_lo
|
||||
let csrf = account["csrfToken"].as_str().unwrap();
|
||||
assert_eq!(csrf.len(), 64);
|
||||
assert_eq!(account["authenticated"], true);
|
||||
let image = json_body(
|
||||
call(
|
||||
&app,
|
||||
request(Method::GET, &format!("/api/images/{id}"), Some(&cookie)),
|
||||
Body::empty(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let motion = image["motion"].as_str().unwrap().to_owned();
|
||||
let page = call(
|
||||
&app,
|
||||
request(Method::GET, "/", Some(&cookie)),
|
||||
@@ -702,6 +724,7 @@ async fn valid_session_protects_cached_and_range_responses_until_csrf_checked_lo
|
||||
"/api/images".into(),
|
||||
format!("/api/images/{id}/thumbnail"),
|
||||
format!("/api/images/{id}/original"),
|
||||
motion.clone(),
|
||||
] {
|
||||
let response = call(
|
||||
&app,
|
||||
@@ -737,14 +760,17 @@ async fn valid_session_protects_cached_and_range_responses_until_csrf_checked_lo
|
||||
}
|
||||
}
|
||||
let original = format!("/api/images/{id}/original");
|
||||
let range = call(
|
||||
&app,
|
||||
request(Method::GET, &original, Some(&cookie)).header(header::RANGE, "bytes=0-7"),
|
||||
Body::empty(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(to_bytes(range.into_body(), 100).await.unwrap().len(), 8);
|
||||
for path in [&original, &motion] {
|
||||
let range = call(
|
||||
&app,
|
||||
request(Method::GET, path, Some(&cookie)).header(header::RANGE, "bytes=0-7"),
|
||||
Body::empty(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(range.headers()[header::CACHE_CONTROL], "private, no-cache");
|
||||
assert_eq!(to_bytes(range.into_body(), 100).await.unwrap().len(), 8);
|
||||
}
|
||||
for (origin, token) in [
|
||||
(ORIGIN, ""),
|
||||
(ORIGIN, "forged"),
|
||||
@@ -791,6 +817,7 @@ async fn valid_session_protects_cached_and_range_responses_until_csrf_checked_lo
|
||||
"/api/gallery".into(),
|
||||
format!("/api/images/{id}/thumbnail"),
|
||||
original,
|
||||
motion,
|
||||
] {
|
||||
assert_eq!(
|
||||
call(
|
||||
|
||||
+119
-2
@@ -12,6 +12,8 @@ use serde::Serialize;
|
||||
use tracing::warn;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::motion::{self, MotionSource};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageRecord {
|
||||
pub id: String,
|
||||
@@ -25,6 +27,7 @@ pub struct ImageRecord {
|
||||
pub size: u64,
|
||||
pub modified_ms: u64,
|
||||
pub(crate) modified_ns: u128,
|
||||
pub motion: Option<MotionSource>,
|
||||
}
|
||||
|
||||
impl ImageRecord {
|
||||
@@ -110,6 +113,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
|
||||
let mut images = Vec::new();
|
||||
let mut albums = BTreeMap::<String, Album>::new();
|
||||
let mut videos = HashMap::<(PathBuf, String), Vec<PathBuf>>::new();
|
||||
for entry in WalkDir::new(root)
|
||||
.follow_links(false)
|
||||
.sort_by_file_name()
|
||||
@@ -139,7 +143,16 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !entry.file_type().is_file() || !is_supported_image(entry.path()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
if motion::is_video_path(entry.path()) {
|
||||
if let Some(key) = motion::pairing_key(entry.path()) {
|
||||
videos.entry(key).or_default().push(entry.into_path());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !is_supported_image(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,6 +207,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
.to_owned();
|
||||
let id = image_id(&relative_path, fingerprint.size, fingerprint.modified_ns);
|
||||
let search_key = relative_path.to_lowercase();
|
||||
let motion = motion::embedded_video(&path);
|
||||
|
||||
images.push(Arc::new(ImageRecord {
|
||||
id,
|
||||
@@ -207,9 +221,39 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
size: fingerprint.size,
|
||||
modified_ms: fingerprint.modified_ms,
|
||||
modified_ns: fingerprint.modified_ns,
|
||||
motion,
|
||||
}));
|
||||
}
|
||||
|
||||
for image in &mut images {
|
||||
if image
|
||||
.motion
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.offset > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let candidates = motion::pairing_key(&image.path).and_then(|key| videos.get_mut(&key));
|
||||
let source = candidates.and_then(|candidates| {
|
||||
candidates.sort_by_key(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("mov"))
|
||||
});
|
||||
candidates.iter().find_map(|path| {
|
||||
if let Some(previous) = &image.motion
|
||||
&& previous.path == *path
|
||||
&& fs::metadata(path).is_ok_and(|metadata| previous.matches_metadata(&metadata))
|
||||
{
|
||||
return Some(previous.clone());
|
||||
}
|
||||
motion::paired_video(path)
|
||||
})
|
||||
});
|
||||
if image.motion != source {
|
||||
Arc::make_mut(image).motion = source;
|
||||
}
|
||||
}
|
||||
|
||||
images.sort_by(|left, right| {
|
||||
natord::compare_ignore_case(&left.relative_path, &right.relative_path)
|
||||
});
|
||||
@@ -238,10 +282,14 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
|
||||
fn index_revision(images: &[Arc<ImageRecord>], albums: &[Album]) -> String {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"pixhelf-index-v2\0");
|
||||
hasher.update(b"pixhelf-index-v6\0");
|
||||
for image in images {
|
||||
hasher.update(image.id.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
if let Some(motion) = &image.motion {
|
||||
hasher.update(motion.id.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
}
|
||||
for album in albums {
|
||||
hasher.update(album.path.as_bytes());
|
||||
@@ -407,6 +455,75 @@ mod tests {
|
||||
assert!(removed.albums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_live_photos_and_tracks_video_changes_without_rebuilding_thumbnails() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(temp.path().join("other")).unwrap();
|
||||
for path in ["Photo.jpg", "other/Photo.jpg"] {
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(temp.path().join(path))
|
||||
.unwrap();
|
||||
}
|
||||
let plain = scan_gallery(temp.path(), None).unwrap();
|
||||
let id = plain
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.name == "Photo.jpg" && image.album.is_empty())
|
||||
.unwrap()
|
||||
.id
|
||||
.clone();
|
||||
let video = temp.path().join("photo.MOV");
|
||||
fs::write(&video, crate::motion::tests::video_bytes()).unwrap();
|
||||
let paired = scan_gallery(temp.path(), Some(&plain)).unwrap();
|
||||
assert_eq!(paired.images.len(), 2);
|
||||
assert!(paired.image(&id).unwrap().motion.is_some());
|
||||
assert!(
|
||||
paired
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.album == "other")
|
||||
.unwrap()
|
||||
.motion
|
||||
.is_none()
|
||||
);
|
||||
assert_ne!(plain.revision, paired.revision);
|
||||
let stable = scan_gallery(temp.path(), Some(&paired)).unwrap();
|
||||
assert_eq!(paired.revision, stable.revision);
|
||||
assert!(Arc::ptr_eq(
|
||||
paired.image(&id).unwrap(),
|
||||
stable.image(&id).unwrap()
|
||||
));
|
||||
let mut bytes = crate::motion::tests::video_bytes();
|
||||
bytes.extend_from_slice(&[0, 0, 0, 8, b'f', b'r', b'e', b'e']);
|
||||
fs::write(&video, bytes).unwrap();
|
||||
let changed = scan_gallery(temp.path(), Some(&paired)).unwrap();
|
||||
assert_ne!(
|
||||
changed.image(&id).unwrap().motion,
|
||||
paired.image(&id).unwrap().motion
|
||||
);
|
||||
assert_ne!(changed.revision, paired.revision);
|
||||
fs::remove_file(video).unwrap();
|
||||
let removed = scan_gallery(temp.path(), Some(&changed)).unwrap();
|
||||
assert!(removed.image(&id).unwrap().motion.is_none());
|
||||
assert_eq!(removed.revision, plain.revision);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn never_pairs_video_symlinks_outside_the_gallery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(gallery.join("photo.jpg"))
|
||||
.unwrap();
|
||||
let outside = temp.path().join("outside.mp4");
|
||||
fs::write(&outside, crate::motion::tests::video_bytes()).unwrap();
|
||||
std::os::unix::fs::symlink(outside, gallery.join("photo.mp4")).unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
assert!(index.images[0].motion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sources_changed_after_the_scan() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod config;
|
||||
mod gallery;
|
||||
mod logging;
|
||||
mod motion;
|
||||
mod photo_details;
|
||||
mod similarity;
|
||||
mod storage;
|
||||
|
||||
+528
@@ -0,0 +1,528 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufReader, Read, Seek, SeekFrom},
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
use quick_xml::{XmlVersion, events::Event, name::ResolveResult, reader::NsReader};
|
||||
|
||||
const CAMERA_NS: &[u8] = b"http://ns.google.com/photos/1.0/camera/";
|
||||
const ITEM_NS: &[u8] = b"http://ns.google.com/photos/1.0/container/item/";
|
||||
const XMP_HEADER: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
|
||||
const MAX_JPEG_HEADER: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MotionSource {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub offset: u64,
|
||||
pub length: u64,
|
||||
pub mime: &'static str,
|
||||
size: u64,
|
||||
modified_ns: u128,
|
||||
}
|
||||
|
||||
impl MotionSource {
|
||||
pub fn matches_metadata(&self, metadata: &fs::Metadata) -> bool {
|
||||
metadata.is_file()
|
||||
&& metadata.len() == self.size
|
||||
&& modified_ns(metadata) == self.modified_ns
|
||||
}
|
||||
|
||||
fn from_file(path: &Path, offset: u64, length: u64) -> Option<Self> {
|
||||
let mut file = File::open(path).ok()?;
|
||||
let metadata = file.metadata().ok()?;
|
||||
if !metadata.is_file() || length == 0 || offset.checked_add(length)? > metadata.len() {
|
||||
return None;
|
||||
}
|
||||
let mime = video_mime(&mut file, offset, length)?;
|
||||
let modified_ns = modified_ns(&metadata);
|
||||
let mut hash = blake3::Hasher::new();
|
||||
hash.update(b"pixhelf-motion-v2\0");
|
||||
hash.update(path.to_str()?.as_bytes());
|
||||
hash.update(&metadata.len().to_le_bytes());
|
||||
hash.update(&modified_ns.to_le_bytes());
|
||||
hash.update(&offset.to_le_bytes());
|
||||
hash.update(&length.to_le_bytes());
|
||||
Some(Self {
|
||||
id: hash.finalize().to_hex().to_string(),
|
||||
path: path.to_owned(),
|
||||
offset,
|
||||
length,
|
||||
mime,
|
||||
size: metadata.len(),
|
||||
modified_ns,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn modified_ns(metadata: &fs::Metadata) -> u128 {
|
||||
metadata
|
||||
.modified()
|
||||
.unwrap_or(UNIX_EPOCH)
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
}
|
||||
|
||||
pub fn is_video_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"mov" | "mp4" | "m4v"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pairing_key(path: &Path) -> Option<(PathBuf, String)> {
|
||||
Some((
|
||||
path.parent()?.to_owned(),
|
||||
path.file_stem()?.to_str()?.to_lowercase(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn paired_video(path: &Path) -> Option<MotionSource> {
|
||||
MotionSource::from_file(path, 0, fs::metadata(path).ok()?.len())
|
||||
}
|
||||
|
||||
// Read only JPEG metadata and container headers, never decode or copy the video while scanning.
|
||||
pub fn embedded_video(path: &Path) -> Option<MotionSource> {
|
||||
let mut reader = BufReader::new(File::open(path).ok()?);
|
||||
let size = reader.get_ref().metadata().ok()?.len();
|
||||
let mut marker = [0; 2];
|
||||
reader.read_exact(&mut marker).ok()?;
|
||||
if marker != [0xff, 0xd8] {
|
||||
return None;
|
||||
}
|
||||
// Samsung's SEF index gives exact clip bounds. Its XMP length can include the
|
||||
// SEF footer, which is not part of the MP4 and must not be sent to the player.
|
||||
if let Some((offset, length)) = samsung_video(&mut reader, size)
|
||||
&& let Some(source) = MotionSource::from_file(path, offset, length)
|
||||
{
|
||||
return Some(source);
|
||||
}
|
||||
reader.seek(SeekFrom::Start(2)).ok()?;
|
||||
while reader.stream_position().ok()? < MAX_JPEG_HEADER {
|
||||
reader.read_exact(&mut marker).ok()?;
|
||||
if marker[0] != 0xff {
|
||||
return None;
|
||||
}
|
||||
while marker[1] == 0xff {
|
||||
reader.read_exact(&mut marker[1..]).ok()?;
|
||||
}
|
||||
if matches!(marker[1], 0xda | 0xd9) {
|
||||
return None;
|
||||
}
|
||||
if matches!(marker[1], 0x01 | 0xd0..=0xd7) {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = [0; 2];
|
||||
reader.read_exact(&mut bytes).ok()?;
|
||||
let length = u16::from_be_bytes(bytes).checked_sub(2)?;
|
||||
if marker[1] == 0xe1 {
|
||||
let mut segment = vec![0; usize::from(length)];
|
||||
reader.read_exact(&mut segment).ok()?;
|
||||
if let Some(xmp) = segment.strip_prefix(XMP_HEADER)
|
||||
&& let Some(length) = video_length(xmp)
|
||||
&& let Some(offset) = size.checked_sub(length)
|
||||
&& offset >= reader.stream_position().ok()?
|
||||
&& let Some(source) = MotionSource::from_file(path, offset, length)
|
||||
{
|
||||
return Some(source);
|
||||
}
|
||||
} else {
|
||||
reader.seek(SeekFrom::Current(i64::from(length))).ok()?;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn samsung_video(reader: &mut (impl Read + Seek), size: u64) -> Option<(u64, u64)> {
|
||||
reader.seek(SeekFrom::Start(size.checked_sub(8)?)).ok()?;
|
||||
let mut footer = [0; 8];
|
||||
reader.read_exact(&mut footer).ok()?;
|
||||
if &footer[4..] != b"SEFT" {
|
||||
return None;
|
||||
}
|
||||
let index_length = u32::from_le_bytes(footer[..4].try_into().ok()?);
|
||||
if !(12..=12 + 4096 * 12).contains(&index_length) {
|
||||
return None;
|
||||
}
|
||||
let index_start = size.checked_sub(u64::from(index_length) + 8)?;
|
||||
reader.seek(SeekFrom::Start(index_start)).ok()?;
|
||||
let mut index = vec![0; index_length as usize];
|
||||
reader.read_exact(&mut index).ok()?;
|
||||
let count = u32::from_le_bytes(index[8..12].try_into().ok()?);
|
||||
if &index[..4] != b"SEFH" || u64::from(count) * 12 + 12 != u64::from(index_length) {
|
||||
return None;
|
||||
}
|
||||
for entry in index[12..].chunks_exact(12) {
|
||||
if u16::from_le_bytes(entry[2..4].try_into().ok()?) != 0x0a30 {
|
||||
continue;
|
||||
}
|
||||
let backward = u64::from(u32::from_le_bytes(entry[4..8].try_into().ok()?));
|
||||
let record_length = u64::from(u32::from_le_bytes(entry[8..].try_into().ok()?));
|
||||
let record_start = index_start.checked_sub(backward)?;
|
||||
if record_start < 2 || record_length < 24 || record_length > backward {
|
||||
return None;
|
||||
}
|
||||
reader.seek(SeekFrom::Start(record_start)).ok()?;
|
||||
let mut record = [0; 24];
|
||||
reader.read_exact(&mut record).ok()?;
|
||||
if record[..4] != entry[..4]
|
||||
|| u32::from_le_bytes(record[4..8].try_into().ok()?) != 16
|
||||
|| &record[8..] != b"MotionPhoto_Data"
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let offset = record_start + 24;
|
||||
let length = record_length - 24;
|
||||
if length == 12 {
|
||||
let mut reference = [0; 12];
|
||||
reader.read_exact(&mut reference).ok()?;
|
||||
if &reference[..4] != b"mpv2" {
|
||||
return None;
|
||||
}
|
||||
let offset = u64::from(u32::from_be_bytes(reference[4..8].try_into().ok()?));
|
||||
let length = u64::from(u32::from_be_bytes(reference[8..].try_into().ok()?));
|
||||
if offset < 2 || offset.checked_add(length)? > record_start {
|
||||
return None;
|
||||
}
|
||||
return Some((offset, length));
|
||||
}
|
||||
return Some((offset, length));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn video_mime(file: &mut File, offset: u64, length: u64) -> Option<&'static str> {
|
||||
let end = offset.checked_add(length)?;
|
||||
let mut position = offset;
|
||||
let mut mime = None;
|
||||
let mut movie = false;
|
||||
let mut media = false;
|
||||
for _ in 0..4096 {
|
||||
if position == end {
|
||||
return (movie && media).then_some(mime?);
|
||||
}
|
||||
if end.checked_sub(position)? < 8 {
|
||||
return None;
|
||||
}
|
||||
file.seek(SeekFrom::Start(position)).ok()?;
|
||||
let mut header = [0; 8];
|
||||
file.read_exact(&mut header).ok()?;
|
||||
let size = u32::from_be_bytes(header[..4].try_into().ok()?);
|
||||
let (size, header_size) = match size {
|
||||
0 => (end - position, 8),
|
||||
1 => {
|
||||
let mut extended = [0; 8];
|
||||
file.read_exact(&mut extended).ok()?;
|
||||
(u64::from_be_bytes(extended), 16)
|
||||
}
|
||||
value => (u64::from(value), 8),
|
||||
};
|
||||
if size < header_size || position.checked_add(size)? > end {
|
||||
return None;
|
||||
}
|
||||
match &header[4..] {
|
||||
b"ftyp" if position == offset && size >= header_size + 8 => {
|
||||
let mut brand = [0; 4];
|
||||
file.read_exact(&mut brand).ok()?;
|
||||
mime = Some(if brand == *b"qt " {
|
||||
"video/quicktime"
|
||||
} else {
|
||||
"video/mp4"
|
||||
});
|
||||
}
|
||||
b"moov" => movie = size > header_size,
|
||||
b"mdat" => media = size > header_size,
|
||||
_ => {}
|
||||
}
|
||||
if mime.is_none() {
|
||||
return None;
|
||||
}
|
||||
position += size;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Item {
|
||||
mime: String,
|
||||
semantic: String,
|
||||
length: Option<u64>,
|
||||
property: Option<Property>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Property {
|
||||
Mime,
|
||||
Semantic,
|
||||
Length,
|
||||
LegacyOffset,
|
||||
}
|
||||
|
||||
fn property(namespace: ResolveResult<'_>, name: &[u8]) -> Option<Property> {
|
||||
match namespace {
|
||||
ResolveResult::Bound(namespace) if namespace.as_ref() == ITEM_NS => match name {
|
||||
b"Mime" => Some(Property::Mime),
|
||||
b"Semantic" => Some(Property::Semantic),
|
||||
b"Length" => Some(Property::Length),
|
||||
_ => None,
|
||||
},
|
||||
ResolveResult::Bound(namespace)
|
||||
if namespace.as_ref() == CAMERA_NS && name == b"MicroVideoOffset" =>
|
||||
{
|
||||
Some(Property::LegacyOffset)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_property(item: &mut Item, property: Property, value: &str, legacy: &mut Option<u64>) {
|
||||
match property {
|
||||
Property::Mime => item.mime = value.trim().to_owned(),
|
||||
Property::Semantic => item.semantic = value.trim().to_owned(),
|
||||
Property::Length => item.length = value.trim().parse().ok(),
|
||||
Property::LegacyOffset => *legacy = value.trim().parse().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve XML namespaces so Android vendor prefixes and RDF attribute/element forms both work.
|
||||
fn video_length(xmp: &[u8]) -> Option<u64> {
|
||||
let mut reader = NsReader::from_reader(xmp);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut stack = Vec::<Item>::new();
|
||||
let mut legacy = None;
|
||||
let mut motion = None;
|
||||
loop {
|
||||
let event = reader.read_event().ok()?;
|
||||
match event {
|
||||
Event::Start(ref element) | Event::Empty(ref element) => {
|
||||
if stack.len() >= 64 {
|
||||
return None;
|
||||
}
|
||||
let (namespace, name) = reader.resolver().resolve_element(element.name());
|
||||
let mut item = Item {
|
||||
property: property(namespace, name.as_ref()),
|
||||
..Item::default()
|
||||
};
|
||||
for attribute in element.attributes() {
|
||||
let attribute = attribute.ok()?;
|
||||
let (namespace, name) = reader.resolver().resolve_attribute(attribute.key);
|
||||
if let Some(property) = property(namespace, name.as_ref()) {
|
||||
let value = attribute
|
||||
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
|
||||
.ok()?;
|
||||
set_property(&mut item, property, &value, &mut legacy);
|
||||
}
|
||||
}
|
||||
stack.push(item);
|
||||
if matches!(event, Event::Empty(_)) {
|
||||
finish_item(stack.pop()?, &mut motion)?;
|
||||
}
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if let Some(property) = stack.last()?.property {
|
||||
let value = text.decode().ok()?;
|
||||
let index = stack.len().checked_sub(2)?;
|
||||
set_property(&mut stack[index], property, &value, &mut legacy);
|
||||
}
|
||||
}
|
||||
Event::End(_) => finish_item(stack.pop()?, &mut motion)?,
|
||||
Event::Eof => break,
|
||||
Event::DocType(_) => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return None;
|
||||
}
|
||||
motion.or(legacy).filter(|length| *length > 0)
|
||||
}
|
||||
|
||||
fn finish_item(item: Item, motion: &mut Option<u64>) -> Option<()> {
|
||||
if item.semantic == "MotionPhoto"
|
||||
&& matches!(item.mime.as_str(), "video/mp4" | "video/quicktime")
|
||||
{
|
||||
if motion.is_some() {
|
||||
return None;
|
||||
}
|
||||
*motion = Some(item.length.filter(|length| *length > 0)?);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn video_bytes() -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for (kind, data) in [
|
||||
(b"ftyp", &b"isom\0\0\0\0isom"[..]),
|
||||
(b"moov", &b"test"[..]),
|
||||
(b"mdat", &b"frames"[..]),
|
||||
] {
|
||||
bytes.extend_from_slice(&((data.len() + 8) as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(kind);
|
||||
bytes.extend_from_slice(data);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn motion_jpeg(path: &Path, xmp: &str, video: &[u8]) {
|
||||
let mut image = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(20, 10)
|
||||
.write_to(&mut image, image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
let image = image.into_inner();
|
||||
let mut bytes = image[..2].to_vec();
|
||||
bytes.extend_from_slice(&[0xff, 0xe1]);
|
||||
bytes.extend_from_slice(&((2 + XMP_HEADER.len() + xmp.len()) as u16).to_be_bytes());
|
||||
bytes.extend_from_slice(XMP_HEADER);
|
||||
bytes.extend_from_slice(xmp.as_bytes());
|
||||
bytes.extend_from_slice(&image[2..]);
|
||||
bytes.extend_from_slice(video);
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
pub fn samsung_jpeg(path: &Path, video: &[u8], reference: bool) {
|
||||
motion_jpeg(path, "<x/>", &[]);
|
||||
let mut bytes = fs::read(path).unwrap();
|
||||
let video_offset = bytes.len();
|
||||
if reference {
|
||||
bytes.extend_from_slice(video);
|
||||
}
|
||||
let record_start = bytes.len();
|
||||
bytes.extend_from_slice(&[0, 0, 0x30, 0x0a]);
|
||||
bytes.extend_from_slice(&16_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"MotionPhoto_Data");
|
||||
if reference {
|
||||
bytes.extend_from_slice(b"mpv2");
|
||||
bytes.extend_from_slice(&(video_offset as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(&(video.len() as u32).to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(video);
|
||||
}
|
||||
let record_length = bytes.len() - record_start;
|
||||
// Additional vendor data can follow the clip before the SEF index.
|
||||
bytes.extend_from_slice(b"unrelated vendor metadata");
|
||||
let backward = bytes.len() - record_start;
|
||||
bytes.extend_from_slice(b"SEFH");
|
||||
bytes.extend_from_slice(&106_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&[0, 0, 0x30, 0x0a]);
|
||||
bytes.extend_from_slice(&(backward as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(record_length as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&24_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(b"SEFT");
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_samsung_sef_clips_and_mpv2_references_without_vendor_metadata() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("samsung.jpg");
|
||||
let video = video_bytes();
|
||||
for reference in [false, true] {
|
||||
samsung_jpeg(&path, &video, reference);
|
||||
let source = embedded_video(&path).unwrap();
|
||||
assert_eq!(source.length, video.len() as u64);
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let end = source.offset + source.length;
|
||||
assert!(end < bytes.len() as u64);
|
||||
assert_eq!(&bytes[source.offset as usize..end as usize], &video);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_samsung_index_offsets_lengths_and_record_names() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("samsung.jpg");
|
||||
samsung_jpeg(&path, &video_bytes(), false);
|
||||
let original = fs::read(&path).unwrap();
|
||||
let index = original.len() - 32;
|
||||
let backward = u32::from_le_bytes(original[index + 16..index + 20].try_into().unwrap());
|
||||
let record = index - backward as usize;
|
||||
for offset in [
|
||||
original.len() - 8,
|
||||
index + 8,
|
||||
index + 16,
|
||||
index + 20,
|
||||
record + 4,
|
||||
record + 8,
|
||||
record + 24,
|
||||
] {
|
||||
let mut bytes = original.clone();
|
||||
bytes[offset..offset + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
assert!(
|
||||
embedded_video(&path).is_none(),
|
||||
"accepted corrupt data at {offset}"
|
||||
);
|
||||
}
|
||||
samsung_jpeg(&path, &video_bytes(), true);
|
||||
let mut bytes = fs::read(&path).unwrap();
|
||||
let reference = bytes.windows(4).position(|bytes| bytes == b"mpv2").unwrap();
|
||||
bytes[reference + 4..reference + 8].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
assert!(embedded_video(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_embedded_video_with_legacy_xmp_and_rejects_stale_metadata() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("motion.jpg");
|
||||
let video = video_bytes();
|
||||
let xmp = format!(
|
||||
r#"<x xmlns:c="http://ns.google.com/photos/1.0/camera/" c:MicroVideoOffset="{}"/>"#,
|
||||
video.len()
|
||||
);
|
||||
motion_jpeg(&path, &xmp, &video);
|
||||
let source = embedded_video(&path).unwrap();
|
||||
assert_eq!(source.length, video.len() as u64);
|
||||
assert!(source.offset > 0);
|
||||
assert!(source.matches_metadata(&fs::metadata(&path).unwrap()));
|
||||
motion_jpeg(&path, &xmp, &[]);
|
||||
assert!(embedded_video(&path).is_none());
|
||||
assert!(!source.matches_metadata(&fs::metadata(&path).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_container_items_without_confusing_gainmap_lengths() {
|
||||
let xmp = br#"<x xmlns:c="http://ns.google.com/photos/1.0/container/" xmlns:v="http://ns.google.com/photos/1.0/container/item/">
|
||||
<c:Item v:Semantic="Primary" v:Mime="image/jpeg" v:Length="0"/>
|
||||
<c:Item v:Semantic="GainMap" v:Mime="image/jpeg" v:Length="999"/>
|
||||
<c:Item><v:Semantic>MotionPhoto</v:Semantic><v:Mime>video/mp4</v:Mime><v:Length>46</v:Length></c:Item>
|
||||
</x>"#;
|
||||
assert_eq!(video_length(xmp), Some(46));
|
||||
assert_eq!(
|
||||
video_length(br#"<x xmlns:c="wrong" c:MicroVideoOffset="46"/>"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(video_length(br#"<x xmlns:c="http://ns.google.com/photos/1.0/camera/" c:MicroVideoOffset="18446744073709551616"/>"#), None);
|
||||
assert_eq!(
|
||||
video_length(
|
||||
br#"<x xmlns:c="http://ns.google.com/photos/1.0/camera/" c:MicroVideoOffset="46">"#
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_video_boxes_and_non_video_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("photo.MOV");
|
||||
let bytes = video_bytes();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
assert!(paired_video(&path).is_some());
|
||||
fs::write(&path, &bytes[..bytes.len() - 1]).unwrap();
|
||||
assert!(paired_video(&path).is_none());
|
||||
fs::write(&path, b"not a video").unwrap();
|
||||
assert!(paired_video(&path).is_none());
|
||||
}
|
||||
}
|
||||
@@ -688,6 +688,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
ImageRecord {
|
||||
motion: None,
|
||||
id: id.to_owned(),
|
||||
path,
|
||||
relative_path: id.to_owned(),
|
||||
|
||||
+487
@@ -244,6 +244,8 @@ struct ImageView {
|
||||
name: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
motion: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&ImageRecord> for ImageView {
|
||||
@@ -253,6 +255,10 @@ impl From<&ImageRecord> for ImageView {
|
||||
name: record.name.clone(),
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
motion: record
|
||||
.motion
|
||||
.as_ref()
|
||||
.map(|motion| format!("/api/images/{}/motion/original/{}", record.id, motion.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +287,10 @@ pub fn router(state: AppState, config: AuthConfig) -> Router {
|
||||
.route("/api/status", get(thumbnail_status))
|
||||
.route("/api/images/{id}/thumbnail", get(thumbnail))
|
||||
.route("/api/images/{id}/original", get(original))
|
||||
.route(
|
||||
"/api/images/{id}/motion/original/{version}",
|
||||
get(motion_video),
|
||||
)
|
||||
.fallback(frontend)
|
||||
.with_state(state)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
@@ -1081,6 +1091,118 @@ async fn original(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn motion_video(
|
||||
State(state): State<AppState>,
|
||||
Path((id, version)): Path<(String, String)>,
|
||||
mut request: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let image = {
|
||||
let index = state.index.read().await;
|
||||
index.image(&id).cloned().ok_or(StatusCode::NOT_FOUND)?
|
||||
};
|
||||
let motion = image
|
||||
.motion
|
||||
.as_ref()
|
||||
.filter(|motion| motion.id == version)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
let image_metadata = tokio::fs::metadata(&image.path)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let video_metadata = tokio::fs::metadata(&motion.path)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
if !image.matches_metadata(&image_metadata) || !motion.matches_metadata(&video_metadata) {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
// Let the browser reuse original bytes when a fresh decoder is needed or
|
||||
// the user returns to a previous photo. The versioned URL and this validator
|
||||
// are separate from retired previews and earlier uncacheable responses.
|
||||
let etag = format!("\"original-motion-v2-{version}\"");
|
||||
let cache_control = "private, max-age=31536000, immutable";
|
||||
if is_not_modified(request.headers(), &etag) {
|
||||
return Ok(not_modified(&etag, cache_control));
|
||||
}
|
||||
// ServeFile's date validator refers to the containing JPEG/MOV, not this
|
||||
// clip representation. Only the clip's own ETag can validate a response.
|
||||
request.headers_mut().remove(header::IF_NONE_MATCH);
|
||||
request.headers_mut().remove(header::IF_MODIFIED_SINCE);
|
||||
|
||||
let range = if request.method() == Method::GET
|
||||
&& request
|
||||
.headers()
|
||||
.get(header::IF_RANGE)
|
||||
.is_none_or(|value| value == etag.as_str())
|
||||
{
|
||||
request
|
||||
.headers()
|
||||
.get(header::RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Multipart ranges may be ignored. The browser uses single ranges to buffer and seek.
|
||||
let range = if let Some(range) = range.filter(|range| !range.contains(',')) {
|
||||
match http_range_header::parse_range_header(range)
|
||||
.and_then(|range| range.validate(motion.length))
|
||||
{
|
||||
Ok(ranges) => ranges.into_iter().next(),
|
||||
Err(_) => {
|
||||
let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes */{}", motion.length).parse().unwrap(),
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let start = range.as_ref().map_or(0, |range| *range.start());
|
||||
let end = range
|
||||
.as_ref()
|
||||
.map_or(motion.length - 1, |range| *range.end());
|
||||
request.headers_mut().remove(header::IF_RANGE);
|
||||
request.headers_mut().insert(
|
||||
header::RANGE,
|
||||
format!("bytes={}-{}", motion.offset + start, motion.offset + end)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// ServeFile streams the selected bytes, including when the clip is inside a JPEG.
|
||||
let response = ServeFile::new(&motion.path)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (parts, body) = response.into_parts();
|
||||
let mut response = Response::from_parts(parts, Body::new(body));
|
||||
if !response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
*response.status_mut() = if range.is_some() {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(motion.mime));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
if range.is_some() {
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{}", motion.length)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
} else {
|
||||
response.headers_mut().remove(header::CONTENT_RANGE);
|
||||
}
|
||||
Ok(with_cache_headers(response, &etag, cache_control))
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
path: std::path::PathBuf,
|
||||
request: Request,
|
||||
@@ -1414,6 +1536,7 @@ mod tests {
|
||||
size: 30,
|
||||
modified_ms: 40,
|
||||
modified_ns: 40,
|
||||
motion: None,
|
||||
};
|
||||
let view = ImageView::from(&record);
|
||||
let json = serde_json::to_string(&view).unwrap();
|
||||
@@ -1423,6 +1546,369 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_photo_video_streams_only_the_clip_and_supports_browser_ranges() {
|
||||
const CACHE_CONTROL: &str = "private, max-age=31536000, immutable";
|
||||
for format in [
|
||||
"paired",
|
||||
"paired-hevc",
|
||||
"xmp",
|
||||
"samsung",
|
||||
"samsung-reference",
|
||||
] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
let photo = gallery.join("photo.jpg");
|
||||
let video = if format == "paired-hevc" {
|
||||
include_bytes!("../frontend/scripts/fixtures/live-photo-hevc.mov").to_vec()
|
||||
} else {
|
||||
crate::motion::tests::video_bytes()
|
||||
};
|
||||
if format.starts_with("samsung") {
|
||||
crate::motion::tests::samsung_jpeg(&photo, &video, format == "samsung-reference");
|
||||
} else if format == "xmp" {
|
||||
let xmp = format!(
|
||||
r#"<x xmlns:c="http://ns.google.com/photos/1.0/camera/" c:MicroVideoOffset="{}"/>"#,
|
||||
video.len()
|
||||
);
|
||||
crate::motion::tests::motion_jpeg(&photo, &xmp, &video);
|
||||
} else {
|
||||
image::RgbImage::new(20, 10).save(&photo).unwrap();
|
||||
std::fs::write(gallery.join("photo.mp4"), &video).unwrap();
|
||||
}
|
||||
let index = crate::gallery::scan_gallery(&gallery, None).unwrap();
|
||||
let record = Arc::clone(&index.images[0]);
|
||||
let url = format!(
|
||||
"/api/images/{}/motion/original/{}",
|
||||
record.id,
|
||||
record.motion.as_ref().unwrap().id
|
||||
);
|
||||
assert_eq!(ImageView::from(record.as_ref()).motion.as_ref(), Some(&url));
|
||||
let app = router(AppState {
|
||||
index: Arc::new(RwLock::new(Arc::new(index))),
|
||||
thumbnails: ThumbnailManager::new(temp.path().join("cache")).unwrap(),
|
||||
});
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(&url).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
record.motion.as_ref().unwrap().mime
|
||||
);
|
||||
assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes");
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_LENGTH],
|
||||
video.len().to_string()
|
||||
);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], CACHE_CONTROL);
|
||||
let etag = response.headers()[header::ETAG].clone();
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap(),
|
||||
video
|
||||
);
|
||||
for (range, start, end) in [
|
||||
("bytes=0-7", 0, 7),
|
||||
("bytes=8-", 8, video.len() - 1),
|
||||
("bytes=-8", video.len() - 8, video.len() - 1),
|
||||
("bytes=8-9999", 8, video.len() - 1),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::RANGE, range)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT, "{range}");
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], CACHE_CONTROL);
|
||||
assert_eq!(response.headers()[header::ETAG], etag);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_RANGE],
|
||||
format!("bytes {start}-{end}/{}", video.len())
|
||||
);
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap(),
|
||||
video[start..=end]
|
||||
);
|
||||
}
|
||||
let head = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::HEAD)
|
||||
.uri(&url)
|
||||
.header(header::RANGE, "bytes=0-7")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(head.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
head.headers()[header::CONTENT_LENGTH],
|
||||
video.len().to_string()
|
||||
);
|
||||
assert!(
|
||||
axum::body::to_bytes(head.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let cached = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::IF_NONE_MATCH, &etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(cached.headers()[header::CACHE_CONTROL], CACHE_CONTROL);
|
||||
assert_eq!(cached.headers()[header::ETAG], etag);
|
||||
assert!(
|
||||
axum::body::to_bytes(cached.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let continuing = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::RANGE, "bytes=0-7")
|
||||
.header(header::IF_RANGE, &etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(continuing.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(continuing.into_body(), 8)
|
||||
.await
|
||||
.unwrap(),
|
||||
video[..8]
|
||||
);
|
||||
let outdated = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::RANGE, "bytes=0-7")
|
||||
.header(header::IF_RANGE, "\"old\"")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outdated.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(outdated.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap(),
|
||||
video
|
||||
);
|
||||
let invalid = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::RANGE, "bytes=9999-")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(
|
||||
invalid.headers()[header::CONTENT_RANGE],
|
||||
format!("bytes */{}", video.len())
|
||||
);
|
||||
let wrong = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/images/{}/motion/original/wrong", record.id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wrong.status(), StatusCode::NOT_FOUND);
|
||||
std::fs::write(&record.motion.as_ref().unwrap().path, b"replaced").unwrap();
|
||||
let stale = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::IF_NONE_MATCH, etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stale.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires PIXHELF_LIVE_SAMPLES with the original Apple and Samsung files"]
|
||||
async fn original_live_samples_bypass_old_cache_and_match_source_bytes() {
|
||||
let samples = std::path::PathBuf::from(
|
||||
std::env::var("PIXHELF_LIVE_SAMPLES").expect("set PIXHELF_LIVE_SAMPLES"),
|
||||
);
|
||||
let index = crate::gallery::scan_gallery(&samples, None).unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cases: Vec<_> = [
|
||||
(
|
||||
"2023-12-27_12-30-56.jpg",
|
||||
"Apple/2023-12-27_12-30-56.mov",
|
||||
0,
|
||||
5_276_784,
|
||||
),
|
||||
(
|
||||
"samsung-one-ui-6.jpg",
|
||||
"motionphoto/samsung-one-ui-6.jpg",
|
||||
4_938_111,
|
||||
3_274_599,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, source, offset, length)| {
|
||||
let record = index
|
||||
.images
|
||||
.iter()
|
||||
.find(|record| record.name == name)
|
||||
.unwrap();
|
||||
let motion = record
|
||||
.motion
|
||||
.as_ref()
|
||||
.expect("sample must have a live clip");
|
||||
assert_eq!(motion.offset, offset as u64);
|
||||
assert_eq!(motion.length, length as u64);
|
||||
let file = std::fs::read(samples.join(source)).unwrap();
|
||||
let expected = file[offset..offset + length].to_vec();
|
||||
assert!(expected.windows(4).any(|bytes| bytes == b"hvc1"));
|
||||
(Arc::clone(record), expected)
|
||||
})
|
||||
.collect();
|
||||
let app = router(AppState {
|
||||
index: Arc::new(RwLock::new(Arc::new(index))),
|
||||
thumbnails: ThumbnailManager::new(temp.path().join("cache")).unwrap(),
|
||||
});
|
||||
for (record, expected) in cases {
|
||||
let motion = record.motion.as_ref().unwrap();
|
||||
let url = ImageView::from(record.as_ref()).motion.unwrap();
|
||||
assert!(url.contains("/motion/original/"));
|
||||
// Both validators can be left behind by an old video response. The
|
||||
// new endpoint must send original bytes, never a 304 for that cache.
|
||||
for validator in [
|
||||
format!("\"motion-{}\"", motion.id),
|
||||
format!("\"original-motion-{}\"", motion.id),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::IF_NONE_MATCH, validator)
|
||||
.header(header::IF_MODIFIED_SINCE, "Wed, 31 Dec 2098 23:59:59 GMT")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, max-age=31536000, immutable"
|
||||
);
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(response.into_body(), 10 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let range = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::RANGE, "bytes=1024-2047")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(
|
||||
range.headers()[header::CACHE_CONTROL],
|
||||
"private, max-age=31536000, immutable"
|
||||
);
|
||||
let original_etag = range.headers()[header::ETAG].clone();
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(range.into_body(), 1024).await.unwrap(),
|
||||
expected[1024..2048]
|
||||
);
|
||||
let replay = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&url)
|
||||
.header(header::IF_NONE_MATCH, original_etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), StatusCode::NOT_MODIFIED);
|
||||
assert!(
|
||||
axum::body::to_bytes(replay.into_body(), 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
for suffix in ["", "/preview-v1.mp4"] {
|
||||
let legacy = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/images/{}/motion/{}{suffix}",
|
||||
record.id, motion.id
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
println!(
|
||||
"{}: {} original HEVC bytes, blake3={}; replay validates without a body, legacy preview unavailable",
|
||||
record.name,
|
||||
expected.len(),
|
||||
blake3::hash(&expected).to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exploration_rank_is_stable_and_seeded() {
|
||||
let first_seed = *blake3::hash(b"first-queue").as_bytes();
|
||||
@@ -1464,6 +1950,7 @@ mod tests {
|
||||
size: 1,
|
||||
modified_ms: 1,
|
||||
modified_ns: 1,
|
||||
motion: None,
|
||||
})
|
||||
};
|
||||
let query = Arc::new(TextSearchEmbedding::for_test([(0, 100)]));
|
||||
|
||||
Reference in new issue
Block a user