From 73847d8107d3baba887b8a80b1be9821d9c20532 Mon Sep 17 00:00:00 2001 From: adminroot Date: Fri, 11 Sep 2026 07:51:45 +0000 Subject: [PATCH] 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. --- Cargo.lock | 13 +- Cargo.toml | 4 +- README.md | 2 + frontend/package-lock.json | 4 +- frontend/package.json | 7 +- frontend/scripts/album-hierarchy-check.mjs | 69 +++ frontend/scripts/albums-check.mjs | 23 +- frontend/scripts/fixtures/README.md | 59 ++ frontend/scripts/fixtures/live-photo-hevc.mov | Bin 0 -> 6262 bytes frontend/scripts/fixtures/live-photo.jpg | Bin 0 -> 881 bytes frontend/scripts/fixtures/live-photo.mp4 | Bin 0 -> 4423 bytes frontend/scripts/live-photo-app-check.mjs | 343 ++++++++++++ frontend/scripts/live-photo-fixture.mjs | 67 +++ .../scripts/live-photo-interaction-check.mjs | 319 +++++++++++ frontend/src/AlbumsView.tsx | 31 +- frontend/src/App.tsx | 5 +- frontend/src/ImageViewer.tsx | 51 +- frontend/src/JustifiedGallery.tsx | 4 +- frontend/src/LivePhoto.tsx | 411 ++++++++++++++ frontend/src/SimilarImages.tsx | 4 +- frontend/src/albums.css | 15 +- frontend/src/api.ts | 1 + frontend/src/cardInteraction.ts | 103 +++- frontend/src/styles.css | 150 ++++- frontend/src/types.ts | 1 + src/auth/tests.rs | 45 +- src/gallery.rs | 121 +++- src/main.rs | 1 + src/motion.rs | 528 ++++++++++++++++++ src/similarity.rs | 1 + src/web.rs | 487 ++++++++++++++++ 31 files changed, 2807 insertions(+), 62 deletions(-) create mode 100644 frontend/scripts/album-hierarchy-check.mjs create mode 100644 frontend/scripts/fixtures/README.md create mode 100644 frontend/scripts/fixtures/live-photo-hevc.mov create mode 100644 frontend/scripts/fixtures/live-photo.jpg create mode 100644 frontend/scripts/fixtures/live-photo.mp4 create mode 100644 frontend/scripts/live-photo-app-check.mjs create mode 100644 frontend/scripts/live-photo-fixture.mjs create mode 100644 frontend/scripts/live-photo-interaction-check.mjs create mode 100644 frontend/src/LivePhoto.tsx create mode 100644 src/motion.rs diff --git a/Cargo.lock b/Cargo.lock index 978a87c..d3ac82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6627a9e..e48a746 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index e34bb37..71cd8e6 100644 --- a/README.md +++ b/README.md @@ -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;查询图片仅用于搜索,不写入图库。 - **账号安全**:设置中可修改用户名和密码,保存后所有设备需重新登录。 - **访客模式**:默认关闭。开启“免登录浏览”后,来访者无需密码即可浏览、搜图和下载;账号设置与外部存储仅限管理员。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78f6ef1..b090ef7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index ad30040..51f79ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/scripts/album-hierarchy-check.mjs b/frontend/scripts/album-hierarchy-check.mjs new file mode 100644 index 0000000..b69da9a --- /dev/null +++ b/frontend/scripts/album-hierarchy-check.mjs @@ -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
+ {album ? <> : <> + setSearch(event.currentTarget.value)} /> + + } +
; + } + render(, 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('
'); + 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(); +} diff --git a/frontend/scripts/albums-check.mjs b/frontend/scripts/albums-check.mjs index 0514711..9c658bf 100644 --- a/frontend/scripts/albums-check.mjs +++ b/frontend/scripts/albums-check.mjs @@ -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, "相册"); diff --git a/frontend/scripts/fixtures/README.md b/frontend/scripts/fixtures/README.md new file mode 100644 index 0000000..9f922c3 --- /dev/null +++ b/frontend/scripts/fixtures/README.md @@ -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. diff --git a/frontend/scripts/fixtures/live-photo-hevc.mov b/frontend/scripts/fixtures/live-photo-hevc.mov new file mode 100644 index 0000000000000000000000000000000000000000..1a41b37178196a8ac6f54a7a8bbff56f3e221831 GIT binary patch literal 6262 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 Ezh7Z(@Bjb+ literal 0 HcmV?d00001 diff --git a/frontend/scripts/fixtures/live-photo.jpg b/frontend/scripts/fixtures/live-photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..15fb77bbbf52e1655bb014834160afa628e9aa41 GIT binary patch literal 881 zcmcgqze~eF7=72on53yq+A0nXMLKj*)Il5s!D6K%SfYYI25HmUMQcbaIy&g);3T+- zi>s5a4i0V#g4DW*e}M?P#_tlOtx&VDGQPCD67PKRGk7d#RcyYu0oDC{^!-g-kzoC4u};PS4aHNJ_R zb8=oek=DTaI`DoDgqDHmJ)o|7FGeMSmG zfP=3BL6k%eS&}*=MOIZsRuokWYih77s3;*L)D_lsLszv(G@?g|>%0i#nSv-&q8?O& zr2Y}^H4GWMI2Bkw0tOQd=H8&&``5l->g}B*$8^@B(Sg(+MrhSLJh$;fC3hW~z~~;q SzyywQakJN&_)jP1K79cr>t(e7 literal 0 HcmV?d00001 diff --git a/frontend/scripts/fixtures/live-photo.mp4 b/frontend/scripts/fixtures/live-photo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ef0ed0d8d5835856131e938bdf6f7c6d9725b5b0 GIT binary patch literal 4423 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 { + 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(); } diff --git a/frontend/scripts/live-photo-fixture.mjs b/frontend/scripts/live-photo-fixture.mjs new file mode 100644 index 0000000..2732b95 --- /dev/null +++ b/frontend/scripts/live-photo-fixture.mjs @@ -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")); +} diff --git a/frontend/scripts/live-photo-interaction-check.mjs b/frontend/scripts/live-photo-interaction-check.mjs new file mode 100644 index 0000000..87140a3 --- /dev/null +++ b/frontend/scripts/live-photo-interaction-check.mjs @@ -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 <> +
+ {images.map(image =>
+ + + +
)} +
+ {open && (window.fixtureRealViewer ? 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} /> :
+ +
+
)} + ; + } + render(, 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: '
' }); + 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(); +} diff --git a/frontend/src/AlbumsView.tsx b/frontend/src/AlbumsView.tsx index 64809ea..4a32558 100644 --- a/frontend/src/AlbumsView.tsx +++ b/frontend/src/AlbumsView.tsx @@ -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 (
@@ -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 ( +
+

子相册 {formatCount(children.length)}

+ +
+ ); +} + export function AlbumHeading({ album, onOpen }: { album: Album; onOpen: (path: string) => void }) { const parts = album.path.split("/"); return ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f79ff0b..f60a3c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 && } + {selectedAlbum && } {loading ? ( ) : images.length ? ( diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx index fb1baf6..5c6780f 100644 --- a/frontend/src/ImageViewer.tsx +++ b/frontend/src/ImageViewer.tsx @@ -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("[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 && } { if (action === "view") { commitTransform(DEFAULT_TRANSFORM); diff --git a/frontend/src/JustifiedGallery.tsx b/frontend/src/JustifiedGallery.tsx index 14c5747..c8642f0 100644 --- a/frontend/src/JustifiedGallery.tsx +++ b/frontend/src/JustifiedGallery.tsx @@ -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({ ) : null} -