Release v0.1.2

This commit is contained in:
adminroot committed 2026-08-22 10:33:52 +00:00
1 parent f6113593d0
commit d2d7c23cbd
21 files changed
+2558 -3298

No files matched your search

Generated
+1 -1
View File
@@ -581,7 +581,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pixhelf"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"axum",
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "pixhelf"
version = "0.1.0"
version = "0.1.2"
edition = "2024"
build = "build.rs"
@@ -26,4 +26,3 @@ tempfile = "3.27.0"
[profile.release]
lto = "thin"
strip = "symbols"
+3 -3
View File
@@ -10,9 +10,9 @@
./target/release/pixhelf
```
首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。
HTTP 服务会立即开放,并优先生成首批 60 张、最长边 720px 的 WebP 缩略图。其余缩略图由后台任务持续处理;新增、修改或删除图片会在定期扫描后自动同步。图库为空时也可正常启动并等待后续图片。
缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
## 构建
@@ -31,7 +31,7 @@ cargo build --release
```text
--gallery-dir PATH 图库目录,默认 ./pic
--cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails
--cache-dir PATH 缩略图缓存,默认 ./.pixhelf-cache/thumbnails
--listen HOST:PORT 监听地址,默认 0.0.0.0:3002
--initial-batch N 启动前生成数量,默认 60
--workers N 后台压缩并发数,默认按 CPU 自动选择 2-4
+1
View File
@@ -14,6 +14,7 @@ fn main() {
] {
println!("cargo:rerun-if-changed={path}");
}
println!("cargo:rerun-if-env-changed=PIXHELF_SKIP_FRONTEND_BUILD");
if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() {
return;
+5 -1
View File
@@ -2,9 +2,13 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#f4f5f4" />
<meta name="description" content="Pixhelf 私人图片画廊" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23287a72'/%3E%3Cpath d='M8 8h7v7H8zm9 0h7v7h-7zM8 17h7v7H8zm9 0h7v7h-7z' fill='white'/%3E%3C/svg%3E" />
<title>Pixhelf</title>
</head>
<body>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pixhelf-frontend",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pixhelf-frontend",
"version": "0.1.0",
"version": "0.1.2",
"dependencies": {
"lucide-react": "^0.539.0",
"react": "^19.1.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "pixhelf-frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+491 -205
View File
@@ -13,51 +13,6 @@ const browser = await chromium.launch({
const results = [];
async function dispatchTouch(cdp, type, points) {
await cdp.send("Input.dispatchTouchEvent", {
type,
touchPoints: points.map((point) => ({
id: point.id,
x: point.x,
y: point.y,
radiusX: 5,
radiusY: 5,
force: 1,
})),
});
}
async function touchTap(cdp, point, pointerId = 1) {
await dispatchTouch(cdp, "touchStart", [{ ...point, id: pointerId }]);
await dispatchTouch(cdp, "touchEnd", []);
}
async function touchDrag(cdp, from, to, steps = 10, pointerId = 1) {
await dispatchTouch(cdp, "touchStart", [{ ...from, id: pointerId }]);
for (let step = 1; step <= steps; step += 1) {
await dispatchTouch(cdp, "touchMove", [{
id: pointerId,
x: from.x + (to.x - from.x) * (step / steps),
y: from.y + (to.y - from.y) * (step / steps),
}]);
}
await dispatchTouch(cdp, "touchEnd", []);
}
async function touchPinch(cdp, center, fromDistance, toDistance, steps = 10) {
const firstStart = { id: 1, x: center.x - fromDistance / 2, y: center.y };
const secondStart = { id: 2, x: center.x + fromDistance / 2, y: center.y };
await dispatchTouch(cdp, "touchStart", [firstStart, secondStart]);
for (let step = 1; step <= steps; step += 1) {
const distance = fromDistance + (toDistance - fromDistance) * (step / steps);
await dispatchTouch(cdp, "touchMove", [
{ id: 1, x: center.x - distance / 2, y: center.y },
{ id: 2, x: center.x + distance / 2, y: center.y },
]);
}
await dispatchTouch(cdp, "touchEnd", []);
}
try {
for (const target of [
{ name: "desktop", width: 1440, height: 1000 },
@@ -81,198 +36,529 @@ try {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
return visible.length >= 8 && visible.every((image) => image.complete && image.naturalWidth > 0);
return visible.length >= 8 &&
visible.every((image) => image.complete && image.naturalWidth > 0);
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(250);
await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` });
const layout = await page.evaluate(() => ({
viewport: [innerWidth, innerHeight],
bodyWidth: document.documentElement.scrollWidth,
cards: document.querySelectorAll("[data-image-id]").length,
masonryColumns: document.querySelectorAll(".masonry-column").length,
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
brokenVisibleImages: [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
}).length,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
}));
const layout = await page.evaluate(() => {
const path = document.querySelector(".desktop-sidebar .sidebar-gallery-path");
const count = document.querySelector(".desktop-sidebar .sidebar-gallery-count");
const search = document.querySelector(".topbar-search");
const topbar = document.querySelector(".topbar");
const sidebarStatus = document.querySelector(".desktop-sidebar .sidebar-status");
const sidebarMeta = document.querySelector(".desktop-sidebar .sidebar-gallery-meta");
const pathRect = path?.getBoundingClientRect();
const countRect = count?.getBoundingClientRect();
const searchRect = search?.getBoundingClientRect();
const exploreRect = document.querySelector(".explore-toggle")?.getBoundingClientRect();
const topbarRect = topbar?.getBoundingClientRect();
const statusRect = sidebarStatus?.getBoundingClientRect();
const metaRect = sidebarMeta?.getBoundingClientRect();
const galleryToggle = document.querySelector(".gallery-sidebar-toggle");
return {
viewport: [innerWidth, innerHeight],
bodyWidth: document.documentElement.scrollWidth,
cards: document.querySelectorAll("[data-image-id]").length,
cardTags: [...document.querySelectorAll("[data-image-id]")]
.map((element) => element.tagName),
masonryColumns: document.querySelectorAll(".masonry-column").length,
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
brokenVisibleImages: [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
}).length,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
dialogs: document.querySelectorAll('[role="dialog"]').length,
topbarPosition: topbar ? getComputedStyle(topbar).position : "",
topbarTop: topbarRect?.top ?? -1,
topbarSearches: document.querySelectorAll(
".topbar-actions > .topbar-search",
).length,
brandHomeLinks: document.querySelectorAll('a.brand[href="/"]').length,
gallerySidebarToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
galleryToggleOpacity: galleryToggle
? Number.parseFloat(getComputedStyle(galleryToggle).opacity)
: -1,
topbarSidebarToggles: document.querySelectorAll(
".topbar .sidebar-toggle-button",
).length,
contentSearches: document.querySelectorAll(".content .search-field").length,
topbarSearchRightGap: searchRect ? innerWidth - searchRect.right : -1,
exploreButtons: document.querySelectorAll(".topbar-actions > .explore-toggle").length,
exploreSearchGap: exploreRect && searchRect ? searchRect.left - exploreRect.right : -1,
sidebarStatuses: document.querySelectorAll(
".desktop-sidebar > .sidebar-status",
).length,
sidebarGalleryMetas: document.querySelectorAll(".sidebar-gallery-meta").length,
contentInfoRows: document.querySelectorAll(
".content-heading, .content .sidebar-gallery-meta",
).length,
sortControls: document.querySelectorAll(".sort-control").length,
cardNameDisplay: getComputedStyle(document.querySelector(".image-name")).display,
cardNameOpacity: Number.parseFloat(
getComputedStyle(document.querySelector(".image-name")).opacity,
),
cardNameWhiteSpace: getComputedStyle(
document.querySelector(".image-name"),
).whiteSpace,
sidebarMetaBottomGap: metaRect ? innerHeight - metaRect.bottom : -1,
sidebarMetaStatusGap: statusRect && metaRect ? metaRect.top - statusRect.bottom : -1,
galleryPath: path?.textContent ?? "",
galleryCount: count?.textContent ?? "",
galleryMetaRowDelta: pathRect && countRect
? Math.abs(pathRect.top - countRect.top)
: -1,
};
});
await page.locator("[data-image-id]").first().click();
await page.waitForSelector(".image-viewer");
await page.waitForFunction(() => {
const image = document.querySelector(".viewer-image-original");
return image instanceof HTMLImageElement &&
image.complete &&
image.naturalWidth > 0 &&
image.classList.contains("is-ready") &&
Number(getComputedStyle(image).opacity) > 0.98;
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(250);
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
await page.waitForTimeout(200);
const dialogsAfterImageClick = await page.locator('[role="dialog"]').count();
const viewerLayout = await page.evaluate(() => {
const viewer = document.querySelector(".image-viewer");
const frame = document.querySelector(".viewer-image-frame");
const image = document.querySelector(".viewer-image-original");
const transform = document.querySelector(".viewer-transform");
if (!viewer || !frame || !(image instanceof HTMLImageElement) || !transform) {
return { visible: false };
}
const rect = frame.getBoundingClientRect();
const imageStyle = getComputedStyle(image);
const searchToggleBefore = await page.locator(".search-toggle").boundingBox();
await page.locator(".search-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "true"
);
await page.waitForTimeout(220);
const searchToggleOpen = await page.locator(".search-toggle").boundingBox();
const searchPanelOpen = await page.locator("#gallery-search-field").boundingBox();
const searchDisclosure = await page.evaluate(() => {
const leading = document.querySelector(".topbar-leading")?.getBoundingClientRect();
const panel = document.querySelector("#gallery-search-field")?.getBoundingClientRect();
const topbar = document.querySelector(".topbar")?.getBoundingClientRect();
return {
visible: true,
frame: [rect.left, rect.top, rect.right, rect.bottom],
naturalSize: [image.naturalWidth, image.naturalHeight],
imageOpacity: imageStyle.opacity,
objectFit: imageStyle.objectFit,
noLegacyLightbox: document.querySelectorAll(".yarl__portal, .yarl__thumbnails_container").length === 0,
controlsVisible: viewer.getAttribute("data-controls-visible"),
transform: getComputedStyle(transform).transform,
expanded: document.querySelector(".search-toggle")?.getAttribute("aria-expanded"),
focused: document.activeElement?.matches("#gallery-search-field input") ?? false,
panelRole: document.querySelector("#gallery-search-field")?.getAttribute("role"),
panelInert: document.querySelector("#gallery-search-field")?.inert ?? true,
leadingGap: leading && panel ? panel.left - leading.right : -1,
panelTop: panel?.top ?? -1,
topbarTop: topbar?.top ?? -1,
topbarBottom: topbar?.bottom ?? -1,
};
});
const frame = await page.locator(".viewer-canvas").boundingBox();
if (!frame) throw new Error("viewer canvas is missing");
const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
const transform = page.locator(".viewer-transform");
await page.mouse.dblclick(center.x + 48, center.y - 32, { delay: 70 });
await page.waitForFunction(() => document.querySelector(".viewer-transform")?.getAttribute("data-zoomed") === "true");
const doubleClickZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
await page.waitForTimeout(700);
const zoomedLayout = await page.evaluate(() => {
const frame = document.querySelector(".viewer-image-frame");
const transform = document.querySelector(".viewer-transform");
if (!frame || !transform) return { frame: [0, 0, 0, 0], scale: 0, rasterScale: 0 };
const rect = frame.getBoundingClientRect();
return {
frame: [rect.left, rect.top, rect.width, rect.height],
scale: Number(transform.getAttribute("data-scale") ?? 0),
rasterScale: Number(transform.getAttribute("data-raster-scale") ?? transform.getAttribute("data-rasterScale") ?? 0),
};
});
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}-zoomed.png` });
await page.mouse.click(center.x, center.y);
await page.waitForTimeout(300);
const singleClickControls = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await page.mouse.wheel(0, -360);
await page.waitForTimeout(350);
const wheelZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
const counterBefore = await page.locator(".viewer-counter").textContent();
await page.keyboard.press("ArrowRight");
await page.waitForFunction((previous) => document.querySelector(".viewer-counter")?.textContent !== previous, counterBefore);
const counterAfter = await page.locator(".viewer-counter").textContent();
let touchChecks = null;
searchDisclosure.panelWidth = searchPanelOpen?.width ?? -1;
searchDisclosure.stationaryToggleDelta = searchToggleBefore && searchToggleOpen
? Math.hypot(
searchToggleBefore.x - searchToggleOpen.x,
searchToggleBefore.y - searchToggleOpen.y,
)
: -1;
await page.locator("#gallery-search-field input").fill("x");
await page.waitForFunction(() =>
document.querySelector(".topbar-search")?.classList.contains("has-query")
);
searchDisclosure.hasQueryClass = true;
await page.locator("#gallery-search-field input").fill("");
await page.waitForFunction(() =>
!document.querySelector(".topbar-search")?.classList.contains("has-query")
);
searchDisclosure.queryClassCleared = true;
if (target.name === "mobile") {
const cdp = await page.context().newCDPSession(page);
const swipeFrame = await page.locator(".viewer-canvas").boundingBox();
if (!swipeFrame) throw new Error("mobile viewer canvas is missing");
const centerPoint = {
x: swipeFrame.x + swipeFrame.width / 2,
y: swipeFrame.y + swipeFrame.height / 2,
};
await page.evaluate(() => {
document.documentElement.style.setProperty("--visual-viewport-top", "24px");
});
await page.waitForTimeout(50);
const compensatedTopbar = await page.locator(".topbar").boundingBox();
const compensatedPanel = await page.locator("#gallery-search-field").boundingBox();
searchDisclosure.compensatedTopbarDelta = compensatedTopbar
? compensatedTopbar.y - searchDisclosure.topbarTop
: -1;
searchDisclosure.compensatedPanelDelta = compensatedPanel
? compensatedPanel.y - searchDisclosure.panelTop
: -1;
await page.evaluate(() => {
document.documentElement.style.setProperty("--visual-viewport-top", "0px");
});
await page.waitForTimeout(50);
}
await page.locator(".search-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "false"
);
searchDisclosure.restored = await page.locator(".search-toggle")
.getAttribute("aria-expanded");
searchDisclosure.panelInertClosed = await page.locator("#gallery-search-field")
.evaluate((panel) => panel.inert);
await page.keyboard.press("0");
await page.waitForTimeout(280);
const pinchBefore = await transform.getAttribute("data-scale");
await touchPinch(cdp, centerPoint, 82, 210);
await page.waitForTimeout(300);
const pinchAfter = await transform.getAttribute("data-scale");
await page.keyboard.press("0");
await page.waitForTimeout(280);
const firstExploreRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "explore" &&
url.searchParams.get("offset") === "0";
});
await page.locator(".explore-toggle").click();
const firstExplore = await firstExploreRequest;
const firstExploreUrl = new URL(firstExplore.url());
const firstExploreResponse = await firstExplore.response();
await firstExploreResponse?.finished();
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
const touchCounterBefore = await page.locator(".viewer-counter").textContent();
await touchDrag(
cdp,
{ x: swipeFrame.x + swipeFrame.width * 0.78, y: centerPoint.y },
{ x: swipeFrame.x + swipeFrame.width * 0.2, y: centerPoint.y + 3 },
const secondExploreRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "explore" &&
url.searchParams.get("offset") === "0";
});
await page.locator(".explore-toggle").click();
const secondExplore = await secondExploreRequest;
const secondExploreUrl = new URL(secondExplore.url());
const secondExploreResponse = await secondExplore.response();
await secondExploreResponse?.finished();
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
const exploration = await page.evaluate(([firstSeed, secondSeed]) => ({
firstSeed,
secondSeed,
exploreMarker: getComputedStyle(
document.querySelector(".explore-toggle"),
"::after",
).content,
searchMarker: getComputedStyle(
document.querySelector(".search-toggle"),
"::after",
).content,
}), [
firstExploreUrl.searchParams.get("seed") ?? "",
secondExploreUrl.searchParams.get("seed") ?? "",
]);
const homeRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "name-asc" &&
url.searchParams.get("offset") === "0";
});
await page.locator("a.brand").click();
const homeResponse = await (await homeRequest).response();
await homeResponse?.finished();
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
const homeNavigation = await page.evaluate(() => ({
href: document.querySelector("a.brand")?.getAttribute("href"),
galleryPath: document.querySelector(
".desktop-sidebar .sidebar-gallery-path",
)?.textContent,
allImagesActive: document.querySelector(".desktop-sidebar .album-link")
?.classList.contains("active") ?? false,
searchValue: document.querySelector("#gallery-search-field input")?.value,
}));
let desktopSidebar = null;
let mobileNavigation = null;
if (target.name === "desktop") {
const contentBefore = await page.locator(".content").boundingBox();
const toggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
const firstAlbumBefore = await page.locator(".desktop-sidebar .album-link").first()
.boundingBox();
const controlSlotBefore = await page.locator(".desktop-sidebar .sidebar-control-slot")
.boundingBox();
const metaBefore = await page.locator(".desktop-sidebar .sidebar-gallery-meta")
.boundingBox();
const galleryBefore = await page.locator(".masonry").boundingBox();
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "true"
);
await page.waitForFunction(
(previous) => document.querySelector(".viewer-counter")?.textContent !== previous,
touchCounterBefore,
await page.waitForTimeout(260);
const contentCollapsed = await page.locator(".content").boundingBox();
const toggleCollapsed = await page.locator(".gallery-sidebar-toggle").boundingBox();
const sidebarCollapsedBox = await page.locator(".desktop-sidebar").boundingBox();
const galleryCollapsed = await page.locator(".masonry").boundingBox();
desktopSidebar = await page.evaluate(() => ({
expanded: document.querySelector(".gallery-sidebar-toggle")?.getAttribute("aria-expanded"),
visibility: getComputedStyle(document.querySelector(".desktop-sidebar")).visibility,
navigationVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .album-nav"),
).visibility,
statusVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .sidebar-status"),
).visibility,
metaVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .sidebar-gallery-meta"),
).visibility,
ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"),
stored: localStorage.getItem("pixhelf.sidebar-collapsed"),
columns: document.querySelectorAll(".masonry-column").length,
redundantHeadings: document.querySelectorAll(".sidebar-heading").length,
pathDetails: document.querySelectorAll(".album-copy small").length,
progressPanels: document.querySelectorAll(".sidebar-progress").length,
inlineToggles: document.querySelectorAll(".desktop-sidebar-inline-toggle").length,
galleryToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
}));
desktopSidebar.contentBefore = contentBefore?.x ?? -1;
desktopSidebar.contentCollapsed = contentCollapsed?.x ?? -1;
desktopSidebar.expandedToggleWidth = toggleBefore?.width ?? -1;
desktopSidebar.expandedToggleHeight = toggleBefore?.height ?? -1;
desktopSidebar.collapsedToggleWidth = toggleCollapsed?.width ?? -1;
desktopSidebar.collapsedToggleHeight = toggleCollapsed?.height ?? -1;
desktopSidebar.albumClearance = controlSlotBefore && firstAlbumBefore
? firstAlbumBefore.y - (controlSlotBefore.y + controlSlotBefore.height)
: -1;
desktopSidebar.toggleControlInsetX = toggleBefore && controlSlotBefore
? toggleBefore.x - controlSlotBefore.x
: -1;
desktopSidebar.toggleControlInsetY = toggleBefore && controlSlotBefore
? toggleBefore.y - controlSlotBefore.y
: -1;
desktopSidebar.stationaryToggleDelta = toggleBefore && toggleCollapsed
? Math.hypot(toggleBefore.x - toggleCollapsed.x, toggleBefore.y - toggleCollapsed.y)
: -1;
desktopSidebar.metaIsBelowAlbums = metaBefore && firstAlbumBefore
? metaBefore.y > firstAlbumBefore.y + firstAlbumBefore.height
: false;
desktopSidebar.expandedGalleryInsetX = toggleBefore && galleryBefore
? toggleBefore.x - galleryBefore.x
: -1;
desktopSidebar.expandedGalleryInsetY = toggleBefore && galleryBefore
? toggleBefore.y - galleryBefore.y
: -1;
desktopSidebar.collapsedGalleryInsetX = toggleCollapsed && galleryCollapsed
? toggleCollapsed.x - galleryCollapsed.x
: -1;
desktopSidebar.collapsedGalleryInsetY = toggleCollapsed && galleryCollapsed
? toggleCollapsed.y - galleryCollapsed.y
: -1;
desktopSidebar.expandedGalleryOverlap = toggleBefore && galleryBefore
? !(
toggleBefore.x + toggleBefore.width <= galleryBefore.x ||
galleryBefore.x + galleryBefore.width <= toggleBefore.x ||
toggleBefore.y + toggleBefore.height <= galleryBefore.y ||
galleryBefore.y + galleryBefore.height <= toggleBefore.y
)
: false;
desktopSidebar.galleryOverlap = toggleCollapsed && galleryCollapsed
? !(
toggleCollapsed.x + toggleCollapsed.width <= galleryCollapsed.x ||
galleryCollapsed.x + galleryCollapsed.width <= toggleCollapsed.x ||
toggleCollapsed.y + toggleCollapsed.height <= galleryCollapsed.y ||
galleryCollapsed.y + galleryCollapsed.height <= toggleCollapsed.y
)
: true;
desktopSidebar.sidebarRight = sidebarCollapsedBox
? sidebarCollapsedBox.x + sidebarCollapsedBox.width
: -1;
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "false"
);
const touchCounterAfter = await page.locator(".viewer-counter").textContent();
const controlsBeforeTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await touchTap(cdp, { x: 18, y: centerPoint.y });
await page.waitForTimeout(330);
const viewerStillOpenAfterBlankTap = await page.locator(".image-viewer").count();
const controlsAfterTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await touchDrag(
cdp,
{ x: centerPoint.x, y: swipeFrame.y + swipeFrame.height * 0.34 },
{ x: centerPoint.x + 3, y: swipeFrame.y + swipeFrame.height * 0.7 },
);
await page.waitForSelector(".image-viewer", { state: "detached" });
touchChecks = {
pinchBefore: Number(pinchBefore),
pinchAfter: Number(pinchAfter),
touchCounterBefore,
touchCounterAfter,
controlsBeforeTap,
controlsAfterTap,
viewerStillOpenAfterBlankTap,
dismissDetached: true,
};
await page.waitForTimeout(260);
const toggleRestored = await page.locator(".gallery-sidebar-toggle").boundingBox();
desktopSidebar.restoredToggleDelta = toggleBefore && toggleRestored
? Math.hypot(toggleBefore.x - toggleRestored.x, toggleBefore.y - toggleRestored.y)
: -1;
desktopSidebar.restored = await page.locator(".gallery-sidebar-toggle")
.getAttribute("aria-expanded");
} else {
await page.keyboard.press("Escape");
await page.waitForSelector(".image-viewer", { state: "detached" });
await page.evaluate(() => window.scrollTo({ top: 320, behavior: "auto" }));
await page.waitForTimeout(100);
const fixedTop = await page.locator(".topbar").boundingBox();
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
const navigationToggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
const galleryBefore = await page.locator(".masonry").boundingBox();
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForSelector(".mobile-nav-layer");
await page.waitForTimeout(240);
const navigationToggleOpen = await page.locator(".gallery-sidebar-toggle").boundingBox();
const navigationLayer = await page.locator(".mobile-nav-layer").boundingBox();
const navigationDrawer = await page.locator(".mobile-sidebar").boundingBox();
const controlSlot = await page.locator(".mobile-sidebar .sidebar-control-slot").boundingBox();
const mobileMeta = await page.locator(".mobile-sidebar .sidebar-gallery-meta").boundingBox();
const mobileStatus = await page.locator(".mobile-sidebar .sidebar-status").boundingBox();
const topbar = await page.locator(".topbar").boundingBox();
mobileNavigation = {
opened: await page.locator(".mobile-nav-layer").count(),
fixedTop: fixedTop?.y ?? -1,
expanded: await page.locator(".gallery-sidebar-toggle").getAttribute("aria-expanded"),
internalToggles: await page.locator(".mobile-sidebar .sidebar-toggle-button").count(),
metaRows: await page.locator(".mobile-sidebar .sidebar-gallery-meta").count(),
galleryPath: await page.locator(".mobile-sidebar .sidebar-gallery-path").textContent(),
galleryCount: await page.locator(".mobile-sidebar .sidebar-gallery-count").textContent(),
layerTopGap: navigationLayer && topbar
? navigationLayer.y - (topbar.y + topbar.height)
: -1,
toggleWidth: navigationToggleBefore?.width ?? -1,
toggleHeight: navigationToggleBefore?.height ?? -1,
expandedToggleWidth: navigationToggleOpen?.width ?? -1,
galleryInsetX: navigationToggleBefore && galleryBefore
? navigationToggleBefore.x - galleryBefore.x
: -1,
galleryInsetY: navigationToggleBefore && galleryBefore
? navigationToggleBefore.y - galleryBefore.y
: -1,
galleryOverlap: navigationToggleBefore && galleryBefore
? !(
navigationToggleBefore.x + navigationToggleBefore.width <= galleryBefore.x ||
galleryBefore.x + galleryBefore.width <= navigationToggleBefore.x ||
navigationToggleBefore.y + navigationToggleBefore.height <= galleryBefore.y ||
galleryBefore.y + galleryBefore.height <= navigationToggleBefore.y
)
: -1,
stationaryToggleDelta: navigationToggleBefore && navigationToggleOpen
? Math.hypot(
navigationToggleBefore.x - navigationToggleOpen.x,
navigationToggleBefore.y - navigationToggleOpen.y,
)
: -1,
toggleControlInsetX: navigationToggleOpen && controlSlot
? navigationToggleOpen.x - controlSlot.x
: -1,
toggleControlInsetY: navigationToggleOpen && controlSlot
? navigationToggleOpen.y - controlSlot.y
: -1,
metaBottomGap: mobileMeta ? target.height - (mobileMeta.y + mobileMeta.height) : -1,
metaStatusGap: mobileMeta && mobileStatus
? mobileMeta.y - (mobileStatus.y + mobileStatus.height)
: -1,
drawerWidth: navigationDrawer?.width ?? -1,
};
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForSelector(".mobile-nav-layer", { state: "detached" });
mobileNavigation.closed = await page.locator(".mobile-nav-layer").count();
mobileNavigation.restored = await page.locator(".gallery-sidebar-toggle")
.getAttribute("aria-expanded");
}
const result = {
name: target.name,
...layout,
viewerLayout,
zoomedLayout,
doubleClickZoom,
wheelZoom,
singleClickControls,
counterBefore,
counterAfter,
touchChecks,
dialogsAfterImageClick,
searchDisclosure,
exploration,
homeNavigation,
desktopSidebar,
mobileNavigation,
browserErrors,
};
results.push(result);
console.log(JSON.stringify(result));
const expectedColumns = target.name === "desktop" ? 5 : 2;
const viewerContained = viewerLayout.visible &&
viewerLayout.frame[0] >= -1 &&
viewerLayout.frame[1] >= -1 &&
viewerLayout.frame[2] <= target.width + 1 &&
viewerLayout.frame[3] <= target.height + 1;
if (
layout.horizontalOverflow ||
layout.brokenVisibleImages ||
layout.masonryColumns !== expectedColumns ||
!viewerContained ||
Number(viewerLayout.imageOpacity) < 0.98 ||
viewerLayout.objectFit !== "contain" ||
!viewerLayout.noLegacyLightbox ||
!doubleClickZoom.includes("matrix") ||
!wheelZoom.includes("matrix") ||
zoomedLayout.scale <= 1.01 ||
zoomedLayout.rasterScale <= 1.01 ||
zoomedLayout.frame[2] <= (viewerLayout.frame[2] - viewerLayout.frame[0]) * 1.1 ||
Math.abs(zoomedLayout.frame[2] / (viewerLayout.frame[2] - viewerLayout.frame[0]) - zoomedLayout.scale) > 0.08 ||
singleClickControls !== "false" ||
counterBefore === counterAfter ||
(target.name === "mobile" && (
!touchChecks ||
touchChecks.pinchAfter <= touchChecks.pinchBefore + 0.2 ||
touchChecks.touchCounterBefore === touchChecks.touchCounterAfter ||
touchChecks.viewerStillOpenAfterBlankTap !== 1 ||
touchChecks.controlsBeforeTap === touchChecks.controlsAfterTap ||
!touchChecks.dismissDetached
layout.cardTags.some((tag) => tag !== "FIGURE") ||
layout.dialogs !== 0 ||
dialogsAfterImageClick !== 0 ||
searchDisclosure.expanded !== "true" ||
!searchDisclosure.focused ||
searchDisclosure.panelRole !== "group" ||
searchDisclosure.panelInert ||
(target.name === "desktop" && searchDisclosure.leadingGap < 6) ||
searchDisclosure.panelWidth < (target.name === "desktop" ? 250 : 200) ||
searchDisclosure.stationaryToggleDelta < 0 ||
searchDisclosure.stationaryToggleDelta > 0.5 ||
searchDisclosure.restored !== "false" ||
!searchDisclosure.panelInertClosed ||
!searchDisclosure.hasQueryClass ||
!searchDisclosure.queryClassCleared ||
searchDisclosure.panelTop - searchDisclosure.topbarBottom < 6 ||
exploration.firstSeed.length !== 32 ||
exploration.secondSeed.length !== 32 ||
exploration.firstSeed === exploration.secondSeed ||
exploration.exploreMarker !== "none" ||
exploration.searchMarker !== "none" ||
homeNavigation.href !== "/" ||
homeNavigation.galleryPath !== "/" ||
!homeNavigation.allImagesActive ||
homeNavigation.searchValue !== "" ||
layout.topbarSearches !== 1 ||
layout.brandHomeLinks !== 1 ||
layout.gallerySidebarToggles !== 1 ||
layout.galleryToggleOpacity < 0.4 ||
layout.galleryToggleOpacity > 0.75 ||
layout.topbarSidebarToggles !== 0 ||
layout.contentSearches !== 0 ||
layout.contentInfoRows !== 0 ||
layout.sortControls !== 0 ||
layout.topbarSearchRightGap < 0 ||
layout.topbarSearchRightGap > 20 ||
layout.exploreButtons !== 1 ||
layout.exploreSearchGap < 4 ||
layout.exploreSearchGap > 8 ||
layout.sidebarStatuses !== 1 ||
layout.sidebarGalleryMetas !== 1 ||
!layout.galleryPath.startsWith("/") ||
!layout.galleryCount.endsWith("张图片") ||
layout.galleryMetaRowDelta < 0 ||
layout.galleryMetaRowDelta > 2 ||
(target.name === "desktop" && (
!desktopSidebar ||
desktopSidebar.expanded !== "false" ||
desktopSidebar.visibility !== "visible" ||
desktopSidebar.navigationVisibility !== "hidden" ||
desktopSidebar.statusVisibility !== "hidden" ||
desktopSidebar.metaVisibility !== "hidden" ||
desktopSidebar.ariaHidden !== "true" ||
desktopSidebar.stored !== "true" ||
desktopSidebar.columns !== 6 ||
desktopSidebar.redundantHeadings !== 0 ||
desktopSidebar.pathDetails !== 0 ||
desktopSidebar.progressPanels !== 0 ||
desktopSidebar.inlineToggles !== 0 ||
desktopSidebar.galleryToggles !== 1 ||
Math.abs(desktopSidebar.expandedToggleWidth - (desktopSidebar.contentBefore - 20)) > 1 ||
Math.abs(desktopSidebar.expandedToggleHeight - 36) > 1 ||
Math.abs(desktopSidebar.collapsedToggleWidth - 40) > 1 ||
Math.abs(desktopSidebar.collapsedToggleHeight - 36) > 1 ||
desktopSidebar.albumClearance < 8 ||
desktopSidebar.toggleControlInsetX < 8 ||
desktopSidebar.toggleControlInsetX > 12 ||
desktopSidebar.toggleControlInsetY < 4 ||
desktopSidebar.toggleControlInsetY > 8 ||
desktopSidebar.stationaryToggleDelta < 0 ||
desktopSidebar.stationaryToggleDelta > 1 ||
!desktopSidebar.metaIsBelowAlbums ||
desktopSidebar.expandedGalleryOverlap ||
!desktopSidebar.galleryOverlap ||
Math.abs(desktopSidebar.sidebarRight) > 1 ||
desktopSidebar.contentBefore < 200 ||
Math.abs(desktopSidebar.contentCollapsed) > 1 ||
desktopSidebar.restoredToggleDelta < 0 ||
desktopSidebar.restoredToggleDelta > 1 ||
layout.sidebarMetaBottomGap < 0 ||
layout.sidebarMetaBottomGap > 1 ||
Math.abs(layout.sidebarMetaStatusGap) > 1 ||
desktopSidebar.restored !== "true"
)) ||
(target.name === "mobile" && (
layout.topbarPosition !== "fixed" ||
Math.abs(layout.topbarTop) > 1 ||
Math.abs(searchDisclosure.compensatedTopbarDelta - 24) > 1 ||
Math.abs(searchDisclosure.compensatedPanelDelta - 24) > 1 ||
mobileNavigation?.opened !== 1 ||
Math.abs(mobileNavigation.fixedTop) > 1 ||
mobileNavigation.expanded !== "true" ||
mobileNavigation.internalToggles !== 0 ||
mobileNavigation.metaRows !== 1 ||
!mobileNavigation.galleryPath?.startsWith("/") ||
!mobileNavigation.galleryCount?.endsWith("张图片") ||
Math.abs(mobileNavigation.layerTopGap) > 1 ||
Math.abs(mobileNavigation.toggleWidth - 40) > 1 ||
Math.abs(mobileNavigation.toggleHeight - 36) > 1 ||
Math.abs(mobileNavigation.expandedToggleWidth - (mobileNavigation.drawerWidth - 20)) > 1 ||
!mobileNavigation.galleryOverlap ||
mobileNavigation.stationaryToggleDelta < 0 ||
mobileNavigation.stationaryToggleDelta > 1 ||
mobileNavigation.toggleControlInsetX < 8 ||
mobileNavigation.toggleControlInsetX > 12 ||
mobileNavigation.toggleControlInsetY < 4 ||
mobileNavigation.toggleControlInsetY > 8 ||
mobileNavigation.metaBottomGap < 0 ||
mobileNavigation.metaBottomGap > 1 ||
Math.abs(mobileNavigation.metaStatusGap) > 1 ||
mobileNavigation.drawerWidth < 200 ||
mobileNavigation.closed !== 0 ||
mobileNavigation.restored !== "false" ||
layout.cardNameDisplay === "none" ||
layout.cardNameOpacity > 0.01 ||
layout.cardNameWhiteSpace !== "nowrap"
)) ||
(target.name === "desktop" && layout.topbarPosition !== "fixed") ||
browserErrors.length
) {
throw new Error(`visual check failed: ${JSON.stringify(result)}`);
+646 -223
View File
File diff suppressed because it is too large. Load diff
-855
View File
@@ -1,855 +0,0 @@
import {
ChevronLeft,
ChevronRight,
Download,
ImageOff,
LoaderCircle,
Maximize,
Minimize,
Minus,
Plus,
RefreshCw,
Scan,
X,
} from "lucide-react";
import {
type CSSProperties,
type SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type { GalleryImage } from "./types";
import { type Point, useViewerEngine } from "./viewer/useViewerEngine";
const DOUBLE_TAP_DELAY = 230;
const VIEWER_PREVIEW_EDGE = 2560;
type SourceStatus = "loading" | "ready" | "failed";
interface SourceState {
imageId: string;
status: SourceStatus;
}
interface PendingTap extends Point {
time: number;
pointerType: string;
}
interface RetryState {
imageId: string;
attempt: number;
}
interface ImageViewerProps {
images: GalleryImage[];
index: number;
total: number;
onIndexChange: (index: number) => void;
onNeedMore: () => void;
onClose: () => void;
}
interface ViewportSize {
width: number;
height: number;
}
function viewportSize(): ViewportSize {
return {
width: Math.round(window.visualViewport?.width ?? window.innerWidth),
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
};
}
function useViewportSize(): ViewportSize {
const [size, setSize] = useState(viewportSize);
useEffect(() => {
let frame = 0;
const update = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => setSize(viewportSize()));
};
window.addEventListener("resize", update);
window.visualViewport?.addEventListener("resize", update);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, []);
return size;
}
function fittedSize(image: GalleryImage, viewport: ViewportSize) {
const compact = viewport.width <= 720;
const availableWidth = Math.max(1, viewport.width - (compact ? 8 : 72));
const availableHeight = Math.max(1, viewport.height - (compact ? 8 : 40));
const ratio = Math.min(
availableWidth / Math.max(image.width, 1),
availableHeight / Math.max(image.height, 1),
1,
);
return {
width: Math.max(1, Math.round(image.width * ratio)),
height: Math.max(1, Math.round(image.height * ratio)),
};
}
function maximumZoom(
image: GalleryImage,
frame: ViewportSize,
previewReady: boolean,
originalReady: boolean,
) {
if (!previewReady && !originalReady) return 1;
const sourceEdge = originalReady
? Math.max(image.width, image.height)
: Math.min(VIEWER_PREVIEW_EDGE, Math.max(image.width, image.height));
const nativeScale = sourceEdge / Math.max(frame.width, frame.height, 1);
return Math.max(1, Math.min(6, nativeScale));
}
function formatCount(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value);
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export default function ImageViewer({
images,
index,
total,
onIndexChange,
onNeedMore,
onClose,
}: ImageViewerProps) {
const image = images[index];
const previous = images[index - 1] ?? null;
const next = images[index + 1] ?? null;
const viewport = useViewportSize();
const compact = viewport.width <= 720;
const frameSize = useMemo(() => fittedSize(image, viewport), [image, viewport]);
const slideSpan = viewport.width + (compact ? 12 : 28);
const [sourceState, setSourceState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
const [previewState, setPreviewState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
const [thumbnailState, setThumbnailState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
// Keep the large source out of the network queue while the sharp preview is
// being fetched. A short timeout keeps the original a reliable fallback on
// slow or unavailable preview endpoints.
const [originalGate, setOriginalGate] = useState({ imageId: image.id, open: false });
const [retryState, setRetryState] = useState<RetryState>({
imageId: image.id,
attempt: 0,
});
const sourceAttempt = retryState.imageId === image.id ? retryState.attempt : 0;
const [controlsVisible, setControlsVisible] = useState(true);
const [closing, setClosing] = useState(false);
const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement));
const [pendingNext, setPendingNext] = useState(false);
const [announcement, setAnnouncement] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const currentImageIdRef = useRef(image.id);
const sourceAttemptRef = useRef(sourceAttempt);
const closeTimerRef = useRef(0);
const controlsTimerRef = useRef(0);
const tapTimerRef = useRef(0);
const pendingTimerRef = useRef(0);
const pendingRetryRef = useRef(0);
const tapRef = useRef<PendingTap | null>(null);
const tapHandlerRef = useRef<(point: Point, pointerType: string) => void>(() => undefined);
currentImageIdRef.current = image.id;
sourceAttemptRef.current = sourceAttempt;
const originalReady = sourceState.imageId === image.id && sourceState.status === "ready";
const originalFailed = sourceState.imageId === image.id && sourceState.status === "failed";
const previewReady = previewState.imageId === image.id && previewState.status === "ready";
const thumbnailReady = thumbnailState.imageId === image.id && thumbnailState.status === "ready";
const originalEnabled = originalGate.imageId === image.id && originalGate.open;
const maxZoom = useMemo(
() => maximumZoom(
image,
frameSize,
previewReady,
originalReady,
),
[frameSize, image, originalReady, previewReady],
);
const clearTap = useCallback(() => {
window.clearTimeout(tapTimerRef.current);
tapTimerRef.current = 0;
tapRef.current = null;
}, []);
const scheduleControlsHide = useCallback(() => {
window.clearTimeout(controlsTimerRef.current);
controlsTimerRef.current = window.setTimeout(() => {
const root = rootRef.current;
if (root?.querySelector(".viewer-chrome:focus-within")) return;
setControlsVisible(false);
}, compact ? 1700 : 1400);
}, [compact]);
const revealControls = useCallback(() => {
setControlsVisible(true);
scheduleControlsHide();
}, [scheduleControlsHide]);
const requestOriginal = useCallback(() => {
setOriginalGate((current) =>
current.imageId === image.id && !current.open
? { imageId: image.id, open: true }
: current,
);
}, [image.id]);
const requestClose = useCallback(() => {
if (closing) return;
clearTap();
window.clearTimeout(controlsTimerRef.current);
setClosing(true);
if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined);
}
closeTimerRef.current = window.setTimeout(onClose, 170);
}, [clearTap, closing, onClose]);
const handleNavigate = useCallback((direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= images.length) return;
clearTap();
setPendingNext(false);
onIndexChange(target);
revealControls();
}, [clearTap, images.length, index, onIndexChange, revealControls]);
const handleEdge = useCallback((direction: -1 | 1) => {
if (direction > 0 && index + 1 < total && !pendingNext) {
setPendingNext(true);
onNeedMore();
}
}, [index, onNeedMore, pendingNext, total]);
const engine = useViewerEngine({
imageId: image.id,
frameSize,
maximumZoom: maxZoom,
canPrevious: Boolean(previous),
canNext: Boolean(next),
onNavigate: handleNavigate,
onEdge: handleEdge,
onDismiss: requestClose,
onTap: (point, pointerType) => tapHandlerRef.current(point, pointerType),
onDragStart: clearTap,
onInteraction: () => {
revealControls();
requestOriginal();
},
});
const commitSingleTap = useCallback(() => {
setControlsVisible((visible) => {
if (visible) window.clearTimeout(controlsTimerRef.current);
else scheduleControlsHide();
return !visible;
});
}, [scheduleControlsHide]);
const handleTap = useCallback((point: Point, pointerType: string) => {
const now = performance.now();
const pending = tapRef.current;
const tapDelay = pointerType === "touch" ? DOUBLE_TAP_DELAY : 200;
const threshold = pointerType === "touch" ? 34 : 24;
const compatible = pending &&
pending.pointerType === pointerType &&
now - pending.time <= tapDelay + 40 &&
Math.hypot(pending.x - point.x, pending.y - point.y) <= threshold;
if (compatible) {
clearTap();
engine.toggleZoomAt(point);
return;
}
if (pending) {
window.clearTimeout(tapTimerRef.current);
commitSingleTap();
}
tapRef.current = { ...point, time: now, pointerType };
tapTimerRef.current = window.setTimeout(() => {
tapRef.current = null;
tapTimerRef.current = 0;
commitSingleTap();
}, tapDelay);
}, [clearTap, commitSingleTap, engine]);
tapHandlerRef.current = handleTap;
const canvasHandlers = {
onPointerDown: engine.handlePointerDown,
onPointerMove: engine.handlePointerMove,
onPointerUp: engine.handlePointerUp,
onPointerCancel: engine.handlePointerCancel,
onLostPointerCapture: engine.handleLostPointerCapture,
};
useEffect(() => {
setAnnouncement(`${image.name},第 ${index + 1} 张,共 ${total} 张`);
}, [image.id, image.name, index, total]);
useEffect(() => {
if (!pendingNext || index + 1 >= images.length) return;
let cancelled = false;
const advance = () => {
if (cancelled) return;
if (engine.navigate(1)) {
setPendingNext(false);
return;
}
pendingRetryRef.current = window.setTimeout(advance, 120);
};
advance();
return () => {
cancelled = true;
window.clearTimeout(pendingRetryRef.current);
};
}, [engine.navigate, images.length, index, pendingNext]);
useEffect(() => {
if (!pendingNext) return;
window.clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = window.setTimeout(() => setPendingNext(false), 8000);
return () => window.clearTimeout(pendingTimerRef.current);
}, [pendingNext]);
useEffect(() => {
if (index >= images.length - 4 && index + 1 < total) onNeedMore();
}, [images.length, index, onNeedMore, total]);
const handleOriginalLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
const attempt = Number(element.dataset.sourceAttempt ?? -1);
try {
await element.decode();
} catch {
// A completed image can still reject decode on some WebKit versions.
}
if (
imageId &&
imageId === currentImageIdRef.current &&
attempt === sourceAttemptRef.current &&
element.isConnected &&
element.complete &&
element.naturalWidth > 0
) {
setSourceState({ imageId, status: "ready" });
}
}, []);
const handleThumbnailLoad = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const imageId = event.currentTarget.dataset.imageId;
if (
imageId &&
imageId === currentImageIdRef.current &&
event.currentTarget.isConnected &&
event.currentTarget.complete &&
event.currentTarget.naturalWidth > 0
) {
setThumbnailState({ imageId, status: "ready" });
}
}, []);
const handlePreviewLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
try {
await element.decode();
} catch {
// A completed image can still reject decode on some WebKit versions.
}
if (
imageId &&
imageId === currentImageIdRef.current &&
element.isConnected &&
element.complete &&
element.naturalWidth > 0
) {
setPreviewState({ imageId, status: "ready" });
}
}, []);
const handlePreviewError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const imageId = event.currentTarget.dataset.imageId;
if (imageId && imageId === currentImageIdRef.current && event.currentTarget.isConnected) {
setPreviewState({ imageId, status: "failed" });
}
}, []);
useEffect(() => {
setSourceState({ imageId: image.id, status: "loading" });
setPreviewState({ imageId: image.id, status: "loading" });
setThumbnailState({ imageId: image.id, status: "loading" });
setOriginalGate({ imageId: image.id, open: false });
const timer = window.setTimeout(() => {
setOriginalGate((current) =>
current.imageId === image.id ? { imageId: image.id, open: true } : current,
);
}, 350);
return () => window.clearTimeout(timer);
}, [image.id]);
useEffect(() => {
const previewFailed = previewState.imageId === image.id && previewState.status === "failed";
if (!previewReady && !previewFailed) return;
setOriginalGate((current) =>
current.imageId === image.id ? { imageId: image.id, open: true } : current,
);
}, [image.id, previewReady, previewState.status]);
const handleOriginalError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
const attempt = Number(element.dataset.sourceAttempt ?? -1);
if (
imageId &&
imageId === currentImageIdRef.current &&
attempt === sourceAttemptRef.current &&
element.isConnected
) {
setSourceState({ imageId, status: "failed" });
}
}, []);
useEffect(() => {
if (!previewReady || !next?.previewUrl) return;
let cancelled = false;
const prefetchTimer = window.setTimeout(() => {
if (cancelled) return;
const preloader = new Image();
preloader.decoding = "async";
preloader.fetchPriority = "low";
preloader.src = next.previewUrl;
}, 280);
return () => {
cancelled = true;
window.clearTimeout(prefetchTimer);
};
}, [image.id, next?.previewUrl, previewReady]);
const retryOriginal = useCallback(() => {
setSourceState({ imageId: image.id, status: "loading" });
setRetryState((current) => ({
imageId: image.id,
attempt: current.imageId === image.id ? current.attempt + 1 : 1,
}));
revealControls();
}, [image.id, revealControls]);
const toggleFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await rootRef.current?.requestFullscreen();
} catch {
// Browsers may deny fullscreen in embedded contexts.
}
}, []);
useEffect(() => {
const update = () => setFullscreen(document.fullscreenElement === rootRef.current);
document.addEventListener("fullscreenchange", update);
return () => document.removeEventListener("fullscreenchange", update);
}, []);
useEffect(() => {
const previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const app = document.querySelector<HTMLElement>(".app-shell");
const previousInert = app?.inert ?? false;
const previousOverflow = document.body.style.overflow;
const previousPadding = document.body.style.paddingRight;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
if (app) app.inert = true;
document.body.style.overflow = "hidden";
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
const frame = window.requestAnimationFrame(() => rootRef.current?.focus());
scheduleControlsHide();
return () => {
window.cancelAnimationFrame(frame);
if (app) app.inert = previousInert;
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPadding;
previousFocus?.focus({ preventScroll: true });
};
}, [scheduleControlsHide]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") {
const controlsHidden = rootRef.current?.dataset.controlsVisible === "false";
const focusable = [...(rootRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])',
) ?? [])].filter((element) =>
element.offsetParent !== null &&
(!controlsHidden || element.classList.contains("viewer-essential"))
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1)!;
if (document.activeElement === rootRef.current) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
return;
}
if (event.altKey || event.ctrlKey || event.metaKey) return;
switch (event.key) {
case "Escape":
event.preventDefault();
requestClose();
break;
case "ArrowLeft":
event.preventDefault();
engine.navigate(-1);
break;
case "ArrowRight":
event.preventDefault();
engine.navigate(1);
break;
case "+":
case "=":
event.preventDefault();
engine.zoomBy(1.4);
break;
case "-":
event.preventDefault();
engine.zoomBy(1 / 1.4);
break;
case "0":
event.preventDefault();
engine.resetZoom();
break;
case "f":
case "F":
event.preventDefault();
void toggleFullscreen();
break;
case " ":
if ((event.target as HTMLElement | null)?.closest("button, a, input, select, textarea")) {
return;
}
event.preventDefault();
commitSingleTap();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [commitSingleTap, engine, requestClose, toggleFullscreen]);
useEffect(() => () => {
window.clearTimeout(closeTimerRef.current);
window.clearTimeout(controlsTimerRef.current);
window.clearTimeout(tapTimerRef.current);
window.clearTimeout(pendingTimerRef.current);
window.clearTimeout(pendingRetryRef.current);
}, []);
const originalUrl = sourceAttempt
? `${image.originalUrl}?retry=${sourceAttempt}`
: image.originalUrl;
const frameStyle = {
width: frameSize.width,
height: frameSize.height,
} satisfies CSSProperties;
return createPortal(
<div
ref={rootRef}
className={`image-viewer ${closing ? "is-closing" : ""}`}
data-controls-visible={controlsVisible}
data-interacting={engine.interacting}
role="dialog"
aria-modal="true"
aria-label="图片查看器"
tabIndex={-1}
onFocusCapture={revealControls}
onPointerMove={(event) => {
if (event.pointerType === "mouse" && event.buttons === 0) revealControls();
}}
>
<div
ref={engine.canvasRef}
className="viewer-canvas"
aria-label={`${image.name},第 ${index + 1} 张,共 ${total} 张`}
{...canvasHandlers}
>
<div ref={engine.trackRef} className="viewer-track">
{previous && (
<NeighborSlide
image={previous}
viewport={viewport}
offset={-slideSpan}
/>
)}
<div className="viewer-slide viewer-slide-current">
<div className="viewer-media" style={frameStyle}>
<div
ref={engine.motionRef}
className="viewer-transform"
data-zoomed="false"
onContextMenu={(event) => event.preventDefault()}
>
<div className="viewer-image-frame">
<img
className="viewer-image viewer-image-thumbnail"
src={image.thumbnailUrl}
alt=""
data-image-id={image.id}
draggable={false}
decoding="async"
loading="eager"
fetchPriority="high"
onLoad={handleThumbnailLoad}
/>
<img
key={`${image.id}-preview`}
className={`viewer-image viewer-image-preview ${previewReady && !originalReady ? "is-ready" : ""}`}
src={image.previewUrl}
alt=""
data-image-id={image.id}
draggable={false}
decoding="async"
fetchPriority="high"
onLoad={handlePreviewLoad}
onError={handlePreviewError}
/>
{originalEnabled && (
<img
key={`${image.id}-${sourceAttempt}`}
className={`viewer-image viewer-image-original ${originalReady ? "is-ready" : ""}`}
src={originalUrl}
alt={image.name}
data-image-id={image.id}
data-source-attempt={sourceAttempt}
draggable={false}
decoding="async"
loading="eager"
fetchPriority="auto"
onLoad={handleOriginalLoad}
onError={handleOriginalError}
/>
)}
{!thumbnailReady && !previewReady && !originalReady && !originalFailed && (
<span className="viewer-loading" aria-label="正在载入原图">
<LoaderCircle className="spin" size={22} />
</span>
)}
</div>
</div>
</div>
</div>
{next && (
<NeighborSlide
image={next}
viewport={viewport}
offset={slideSpan}
/>
)}
</div>
</div>
<header
className="viewer-topbar viewer-chrome"
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
onPointerLeave={scheduleControlsHide}
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
onBlurCapture={scheduleControlsHide}
>
<div className="viewer-about">
<span className="viewer-counter">
{formatCount(index + 1)} <i>/</i> {formatCount(total)}
</span>
<span className="viewer-file-copy">
<strong title={image.name}>{image.name}</strong>
<small>
{formatCount(image.width)} x {formatCount(image.height)} · {formatBytes(image.size)}
</small>
</span>
</div>
</header>
<ViewerButton
label="关闭查看器"
className="viewer-close viewer-essential"
onClick={requestClose}
>
<X size={22} />
</ViewerButton>
{!compact && (
<>
<ViewerButton
label="上一张"
className="viewer-nav viewer-nav-previous viewer-chrome"
onClick={() => engine.navigate(-1)}
disabled={!previous}
>
<ChevronLeft size={28} />
</ViewerButton>
<ViewerButton
label={pendingNext ? "正在载入下一张" : "下一张"}
className="viewer-nav viewer-nav-next viewer-chrome"
onClick={() => engine.navigate(1)}
disabled={index + 1 >= total}
>
{pendingNext ? <LoaderCircle className="spin" size={21} /> : <ChevronRight size={28} />}
</ViewerButton>
</>
)}
<div
className="viewer-dock viewer-chrome"
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
onPointerLeave={scheduleControlsHide}
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
onBlurCapture={scheduleControlsHide}
>
{!compact && (
<>
<ViewerButton label="缩小" onClick={() => engine.zoomBy(1 / 1.4)} disabled={!engine.canZoom}>
<Minus size={19} />
</ViewerButton>
<output className="viewer-zoom-value" aria-live="off">
{engine.settledScale <= 1.01 ? "适应" : `${Math.round(engine.settledScale * 100)}%`}
</output>
<ViewerButton label="放大" onClick={() => engine.zoomBy(1.4)} disabled={!engine.canZoom}>
<Plus size={19} />
</ViewerButton>
</>
)}
<ViewerButton label="适应窗口" onClick={engine.resetZoom} disabled={engine.settledScale <= 1.01}>
<Scan size={19} />
</ViewerButton>
<span className="viewer-dock-divider" aria-hidden="true" />
<a
className="viewer-icon-button"
href={image.originalUrl}
download={image.name}
aria-label="下载原图"
title="下载原图"
>
<Download size={19} />
</a>
{!compact && (
<ViewerButton
label={fullscreen ? "退出全屏" : "进入全屏"}
onClick={() => void toggleFullscreen()}
>
{fullscreen ? <Minimize size={19} /> : <Maximize size={19} />}
</ViewerButton>
)}
</div>
{originalFailed && (
<div className="viewer-source-error viewer-chrome" role="status">
<ImageOff size={17} />
<span>原图载入失败</span>
<button type="button" onClick={retryOriginal}>
<RefreshCw size={15} />
重试
</button>
</div>
)}
<p className="sr-only" aria-live="polite">{announcement}</p>
</div>,
document.body,
);
}
function NeighborSlide({
image,
viewport,
offset,
}: {
image: GalleryImage;
viewport: ViewportSize;
offset: number;
}) {
const size = fittedSize(image, viewport);
return (
<div
className="viewer-slide viewer-slide-neighbor"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
aria-hidden="true"
>
<div className="viewer-neighbor-frame" style={{ width: size.width, height: size.height }}>
<img
src={image.thumbnailUrl}
alt=""
draggable={false}
decoding="async"
loading="lazy"
fetchPriority="low"
/>
</div>
</div>
);
}
function ViewerButton({
label,
onClick,
className = "",
disabled = false,
children,
}: {
label: string;
onClick: () => void;
className?: string;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`viewer-icon-button ${className}`}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+99
View File
@@ -0,0 +1,99 @@
import {
type ReactNode,
useEffect,
useRef,
} from "react";
type ToolbarPopoverProps = {
children: ReactNode;
closeLabel: string;
icon: ReactNode;
id: string;
open: boolean;
openLabel: string;
onOpenChange: (open: boolean) => void;
panelClassName?: string;
panelLabel: string;
rootClassName?: string;
triggerClassName?: string;
};
export function ToolbarPopover({
children,
closeLabel,
icon,
id,
open,
openLabel,
onOpenChange,
panelClassName = "",
panelLabel,
rootClassName = "",
triggerClassName = "",
}: ToolbarPopoverProps) {
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const closeFromOutside = (event: PointerEvent) => {
const root = rootRef.current;
if (event.target instanceof Node && root?.contains(event.target)) return;
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && root?.contains(activeElement)) {
activeElement.blur();
}
onOpenChange(false);
};
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
onOpenChange(false);
triggerRef.current?.focus({ preventScroll: true });
};
document.addEventListener("pointerdown", closeFromOutside, true);
document.addEventListener("keydown", closeFromKeyboard);
return () => {
document.removeEventListener("pointerdown", closeFromOutside, true);
document.removeEventListener("keydown", closeFromKeyboard);
};
}, [onOpenChange, open]);
const triggerLabel = open ? closeLabel : openLabel;
return (
<div
ref={rootRef}
className={`toolbar-popover ${open ? "is-open" : ""} ${rootClassName}`}
data-popover-open={open}
>
<button
ref={triggerRef}
type="button"
className={`icon-button toolbar-popover-trigger ${triggerClassName}`}
onClick={() => onOpenChange(!open)}
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={id}
title={triggerLabel}
>
<span className="toolbar-popover-trigger-icon" aria-hidden="true">
{icon}
</span>
</button>
<div
id={id}
className={`toolbar-popover-panel ${panelClassName}`}
role="group"
aria-label={panelLabel}
aria-hidden={!open}
inert={!open}
>
{children}
</div>
</div>
);
}
+42 -5
View File
@@ -5,12 +5,48 @@ import type {
ThumbnailStatus,
} from "./types";
const REQUEST_TIMEOUT_MS = 15_000;
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`请求失败 (${response.status})`);
const controller = new AbortController();
let timedOut = false;
const abortFromCaller = () => controller.abort(signal?.reason);
if (signal?.aborted) {
abortFromCaller();
} else {
signal?.addEventListener("abort", abortFromCaller, { once: true });
}
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
cache: "no-store",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`请求失败 (${response.status})`);
}
if (!response.headers.get("content-type")?.includes("application/json")) {
throw new Error("服务器返回了无效数据");
}
try {
return (await response.json()) as T;
} catch (error) {
if (controller.signal.aborted) throw error;
throw new Error("服务器返回了无效数据", { cause: error });
}
} catch (error) {
if (timedOut) throw new Error("请求超时,请稍后重试", { cause: error });
throw error;
} finally {
window.clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
}
return (await response.json()) as T;
}
export function getGallery(signal?: AbortSignal): Promise<GallerySummary> {
@@ -26,6 +62,7 @@ export function getImages(
album: string;
search: string;
sort: SortMode;
seed: string;
offset: number;
limit: number;
},
@@ -38,6 +75,6 @@ export function getImages(
});
if (options.album) params.set("album", options.album);
if (options.search) params.set("search", options.search);
if (options.seed) params.set("seed", options.seed);
return getJson<ImagesPage>(`/api/images?${params}`, signal);
}
+4 -2
View File
@@ -3,9 +3,11 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root application container");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+673 -703
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -7,6 +7,7 @@ export interface Album {
export interface GallerySummary {
total: number;
albums: Album[];
revision: string;
}
export interface GalleryImage {
@@ -19,8 +20,6 @@ export interface GalleryImage {
size: number;
modifiedMs: number;
thumbnailUrl: string;
previewUrl: string;
originalUrl: string;
}
export interface ImagesPage {
@@ -41,4 +40,4 @@ export interface ThumbnailStatus {
backgroundComplete: boolean;
}
export type SortMode = "name-asc" | "name-desc" | "newest";
export type SortMode = "name-asc" | "name-desc" | "newest" | "explore";
-826
View File
@@ -1,826 +0,0 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
interface Motion extends Point {
scale: number;
}
interface CanvasGeometry extends Point, Size {}
type VisualUpdate =
| { kind: "motion"; motion: Motion }
| { kind: "track"; motion: Motion };
type GestureMode = "idle" | "pending" | "navigation" | "dismiss" | "pan" | "pinch";
interface Gesture {
mode: GestureMode;
pointerId: number;
pointerType: string;
start: Point;
last: Point;
lastTime: number;
velocity: Point;
origin: Motion;
pinchDistance: number;
pinchMidpoint: Point;
canvasCenter: Point;
moved: boolean;
}
interface EngineOptions {
imageId: string;
frameSize: Size;
maximumZoom: number;
canPrevious: boolean;
canNext: boolean;
onNavigate: (direction: -1 | 1) => void;
onEdge: (direction: -1 | 1) => void;
onDismiss: () => void;
onTap: (point: Point, pointerType: string) => void;
onDragStart: () => void;
onInteraction: () => void;
}
interface AnimateOptions {
duration?: number;
commitRaster?: boolean;
onComplete?: () => void;
}
const EMPTY_GESTURE: Gesture = {
mode: "idle",
pointerId: -1,
pointerType: "mouse",
start: { x: 0, y: 0 },
last: { x: 0, y: 0 },
lastTime: 0,
velocity: { x: 0, y: 0 },
origin: { x: 0, y: 0, scale: 1 },
pinchDistance: 0,
pinchMidpoint: { x: 0, y: 0 },
canvasCenter: { x: 0, y: 0 },
moved: false,
};
const RASTER_PIXEL_BUDGET = 40_000_000;
const RASTER_EDGE_LIMIT = 8192;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function magnitude(point: Point): number {
return Math.hypot(point.x, point.y);
}
function distance(first: Point, second: Point): number {
return Math.hypot(first.x - second.x, first.y - second.y);
}
function midpoint(first: Point, second: Point): Point {
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
}
function resist(value: number, limit: number, factor = 0.2): number {
if (value > limit) return limit + (value - limit) * factor;
if (value < -limit) return -limit + (value + limit) * factor;
return value;
}
function easeOutQuart(value: number): number {
return 1 - (1 - value) ** 4;
}
function reducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function useViewerEngine(options: EngineOptions) {
const optionsRef = useRef(options);
optionsRef.current = options;
const canvasRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const motionRef = useRef<HTMLDivElement>(null);
const animationRef = useRef(0);
const trackAnimationRef = useRef(0);
const inputFrameRef = useRef(0);
const navigatingRef = useRef(false);
const navigationUnlockRef = useRef(0);
const rasterTimerRef = useRef(0);
const pointersRef = useRef(new Map<number, Point>());
const gestureRef = useRef<Gesture>({ ...EMPTY_GESTURE });
const layoutImageRef = useRef(options.imageId);
const currentRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const targetRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const trackMotionRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const queuedVisualRef = useRef<VisualUpdate | null>(null);
const geometryRef = useRef<CanvasGeometry>({
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight,
});
const rasterScaleRef = useRef(1);
const [settledScale, setSettledScale] = useState(1);
const [interacting, setInteracting] = useState(false);
const captureGeometry = useCallback(() => {
const rect = canvasRef.current?.getBoundingClientRect();
geometryRef.current = {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
width: rect?.width ?? window.innerWidth,
height: rect?.height ?? window.innerHeight,
};
}, []);
const pageSpan = useCallback(() => {
const width = geometryRef.current.width;
return width + (width <= 720 ? 12 : 28);
}, []);
const boundsFor = useCallback((scale: number) => {
const { frameSize } = optionsRef.current;
const { width, height } = geometryRef.current;
return {
x: Math.max(0, (frameSize.width * scale - width) / 2),
y: Math.max(0, (frameSize.height * scale - height) / 2),
};
}, []);
const constrain = useCallback((motion: Motion): Motion => {
const maximumZoom = Math.max(1, optionsRef.current.maximumZoom);
const scale = clamp(motion.scale, 1, maximumZoom);
const bounds = boundsFor(scale);
return {
x: clamp(motion.x, -bounds.x, bounds.x),
y: clamp(motion.y, -bounds.y, bounds.y),
scale,
};
}, [boundsFor]);
const applyMotion = useCallback((motion: Motion) => {
const element = motionRef.current;
if (!element) return;
const rasterScale = rasterScaleRef.current;
element.style.transform = `translate3d(${motion.x.toFixed(2)}px, ${motion.y.toFixed(2)}px, 0) scale(${(motion.scale / rasterScale).toFixed(4)})`;
element.dataset.zoomed = String(motion.scale > 1.01);
}, []);
const commitRaster = useCallback((
motion = currentRef.current,
settle = true,
displayedMotion = motion,
) => {
const element = motionRef.current;
if (!element) return;
const { frameSize } = optionsRef.current;
const density = clamp(window.devicePixelRatio || 1, 1, 3);
const areaScale = Math.sqrt(
RASTER_PIXEL_BUDGET /
Math.max(frameSize.width * frameSize.height * density * density, 1),
);
const edgeScale = Math.min(
RASTER_EDGE_LIMIT / Math.max(frameSize.width * density, 1),
RASTER_EDGE_LIMIT / Math.max(frameSize.height * density, 1),
);
const maximumRasterScale = Math.max(1, Math.min(areaScale, edgeScale));
const requestedRasterScale = motion.scale <= 1.01
? 1
: Math.max(1, Math.min(motion.scale, maximumRasterScale));
// Keep an already-promoted backing layer while zoomed. Dropping it on
// every zoom-out makes the browser resample a smaller bitmap and can
// soften the next quick pinch. The fit state still releases it.
const rasterScale = motion.scale <= 1.01
? 1
: Math.max(
requestedRasterScale,
Math.min(rasterScaleRef.current, maximumRasterScale),
);
rasterScaleRef.current = rasterScale;
const snap = (value: number) => Math.round(value * density) / density;
const width = snap(frameSize.width * rasterScale);
const height = snap(frameSize.height * rasterScale);
const left = snap((frameSize.width - width) / 2);
const top = snap((frameSize.height - height) / 2);
element.style.width = `${width.toFixed(2)}px`;
element.style.height = `${height.toFixed(2)}px`;
element.style.left = `${left.toFixed(2)}px`;
element.style.top = `${top.toFixed(2)}px`;
element.style.setProperty("--viewer-will-change", settle ? "auto" : "transform");
element.dataset.scale = motion.scale.toFixed(4);
element.dataset.rasterScale = rasterScale.toFixed(4);
// A preheat can happen before an animation reaches its destination. Keep
// the currently displayed transform while changing the backing layer so
// promotion itself never causes a visible jump.
applyMotion(displayedMotion);
if (settle) setSettledScale(motion.scale);
}, [applyMotion]);
// Keep a little headroom in the backing layer while the user is zooming.
// Rebuilding on every pointer event is expensive, so only promote it after
// a meaningful scale increase. Zooming out can keep the larger layer and
// therefore remains sharp without another allocation.
const prepareRaster = useCallback((motion: Motion) => {
const scale = Math.max(1, motion.scale);
const currentRaster = rasterScaleRef.current;
if (scale <= currentRaster * 1.25) return;
commitRaster(constrain(motion), false, currentRef.current);
}, [commitRaster, constrain]);
const scheduleRaster = useCallback((delay = 100) => {
window.clearTimeout(rasterTimerRef.current);
rasterTimerRef.current = window.setTimeout(() => commitRaster(), delay);
}, [commitRaster]);
const stopImageAnimation = useCallback(() => {
window.cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
motionRef.current?.style.setProperty("--viewer-will-change", "auto");
}, []);
const setMotion = useCallback((motion: Motion) => {
currentRef.current = motion;
targetRef.current = motion;
applyMotion(motion);
}, [applyMotion]);
const animateMotion = useCallback((destination: Motion, animation: AnimateOptions = {}) => {
stopImageAnimation();
window.clearTimeout(rasterTimerRef.current);
const target = constrain(destination);
prepareRaster(target);
targetRef.current = target;
const duration = reducedMotion() ? 0 : (animation.duration ?? 240);
if (!duration) {
setMotion(target);
if (animation.commitRaster !== false) commitRaster(target);
animation.onComplete?.();
return;
}
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
const start = currentRef.current;
const startedAt = performance.now();
const tick = (time: number) => {
const progress = clamp((time - startedAt) / duration, 0, 1);
const eased = easeOutQuart(progress);
const next = {
x: start.x + (target.x - start.x) * eased,
y: start.y + (target.y - start.y) * eased,
scale: start.scale + (target.scale - start.scale) * eased,
};
currentRef.current = next;
applyMotion(next);
if (progress < 1) {
animationRef.current = window.requestAnimationFrame(tick);
return;
}
animationRef.current = 0;
currentRef.current = target;
applyMotion(target);
if (animation.commitRaster !== false) commitRaster(target);
animation.onComplete?.();
};
animationRef.current = window.requestAnimationFrame(tick);
}, [applyMotion, commitRaster, constrain, prepareRaster, setMotion, stopImageAnimation]);
const applyTrack = useCallback((x: number, y = 0, scale = 1) => {
const canvas = canvasRef.current;
const progress = clamp(
Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1),
0,
1,
);
trackMotionRef.current = { x, y, scale };
const track = trackRef.current;
if (track) {
track.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;
}
if (canvas) {
canvas.style.setProperty("--viewer-dismiss-progress", progress.toFixed(4));
}
}, []);
const flushVisual = useCallback(() => {
window.cancelAnimationFrame(inputFrameRef.current);
inputFrameRef.current = 0;
const update = queuedVisualRef.current;
queuedVisualRef.current = null;
if (!update) return;
if (update.kind === "motion") applyMotion(update.motion);
else applyTrack(update.motion.x, update.motion.y, update.motion.scale);
}, [applyMotion, applyTrack]);
const queueVisual = useCallback((update: VisualUpdate) => {
queuedVisualRef.current = update;
if (inputFrameRef.current) return;
inputFrameRef.current = window.requestAnimationFrame(() => {
inputFrameRef.current = 0;
const pending = queuedVisualRef.current;
queuedVisualRef.current = null;
if (!pending) return;
if (pending.kind === "motion") applyMotion(pending.motion);
else applyTrack(pending.motion.x, pending.motion.y, pending.motion.scale);
});
}, [applyMotion, applyTrack]);
const stopTrackAnimation = useCallback(() => {
window.cancelAnimationFrame(trackAnimationRef.current);
trackAnimationRef.current = 0;
trackRef.current?.style.setProperty("will-change", "auto");
}, []);
const animateTrack = useCallback((
from: Point & { scale: number },
to: Point & { scale: number },
duration: number,
onComplete?: () => void,
) => {
stopTrackAnimation();
trackRef.current?.style.setProperty("will-change", "transform");
const actualDuration = reducedMotion() ? 0 : duration;
if (!actualDuration) {
applyTrack(to.x, to.y, to.scale);
trackRef.current?.style.setProperty("will-change", "auto");
onComplete?.();
return;
}
const startedAt = performance.now();
const tick = (time: number) => {
const progress = clamp((time - startedAt) / actualDuration, 0, 1);
const eased = easeOutQuart(progress);
applyTrack(
from.x + (to.x - from.x) * eased,
from.y + (to.y - from.y) * eased,
from.scale + (to.scale - from.scale) * eased,
);
if (progress < 1) {
trackAnimationRef.current = window.requestAnimationFrame(tick);
return;
}
trackAnimationRef.current = 0;
trackRef.current?.style.setProperty("will-change", "auto");
onComplete?.();
};
trackAnimationRef.current = window.requestAnimationFrame(tick);
}, [applyTrack, stopTrackAnimation]);
const zoomAround = useCallback((base: Motion, scale: number, point: Point): Motion => {
const geometry = geometryRef.current;
const center = {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
};
const ratio = scale / Math.max(base.scale, 0.001);
return constrain({
x: point.x - center.x - (point.x - center.x - base.x) * ratio,
y: point.y - center.y - (point.y - center.y - base.y) * ratio,
scale,
});
}, [constrain]);
const resetZoom = useCallback(() => {
optionsRef.current.onInteraction();
animateMotion({ x: 0, y: 0, scale: 1 }, { duration: 180 });
}, [animateMotion]);
const zoomTo = useCallback((scale: number, point?: Point) => {
const maximum = Math.max(1, optionsRef.current.maximumZoom);
const nextScale = clamp(scale, 1, maximum);
const geometry = geometryRef.current;
const anchor = point ?? {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
};
optionsRef.current.onInteraction();
animateMotion(zoomAround(currentRef.current, nextScale, anchor), { duration: 180 });
}, [animateMotion, zoomAround]);
const zoomBy = useCallback((factor: number) => {
zoomTo(currentRef.current.scale * factor);
}, [zoomTo]);
const toggleZoomAt = useCallback((point: Point) => {
if (currentRef.current.scale > 1.08) {
resetZoom();
return;
}
zoomTo(Math.min(4, Math.max(1, optionsRef.current.maximumZoom)), point);
}, [resetZoom, zoomTo]);
const completeNavigation = useCallback((direction: -1 | 1) => {
const sourceId = optionsRef.current.imageId;
optionsRef.current.onNavigate(direction);
window.clearTimeout(navigationUnlockRef.current);
navigationUnlockRef.current = window.setTimeout(() => {
navigatingRef.current = false;
if (optionsRef.current.imageId === sourceId) applyTrack(0, 0, 1);
}, 500);
}, [applyTrack]);
const navigate = useCallback((direction: -1 | 1) => {
if (navigatingRef.current) return false;
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
if (!available) {
optionsRef.current.onEdge(direction);
return false;
}
optionsRef.current.onDragStart();
flushVisual();
navigatingRef.current = true;
const slide = () => {
const destination = -direction * pageSpan();
animateTrack(
trackMotionRef.current,
{ x: destination, y: 0, scale: 1 },
260,
() => completeNavigation(direction),
);
};
if (currentRef.current.scale > 1.01) {
animateMotion(
{ x: 0, y: 0, scale: 1 },
{ duration: 160, onComplete: slide },
);
} else {
stopImageAnimation();
slide();
}
return true;
}, [animateMotion, animateTrack, completeNavigation, flushVisual, pageSpan, stopImageAnimation]);
const beginPinch = useCallback(() => {
const pointers = [...pointersRef.current.values()];
if (pointers.length < 2) return;
const first = pointers[0];
const second = pointers[1];
const geometry = geometryRef.current;
flushVisual();
applyTrack(0, 0, 1);
optionsRef.current.onDragStart();
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pinch",
origin: currentRef.current,
pinchDistance: Math.max(1, distance(first, second)),
pinchMidpoint: midpoint(first, second),
canvasCenter: {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
},
moved: true,
};
setInteracting(true);
}, [applyTrack, flushVisual]);
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (navigatingRef.current) return;
event.preventDefault();
captureGeometry();
flushVisual();
stopImageAnimation();
stopTrackAnimation();
window.clearTimeout(rasterTimerRef.current);
targetRef.current = currentRef.current;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Synthetic events and older WebViews may not expose pointer capture.
}
const point = { x: event.clientX, y: event.clientY };
pointersRef.current.set(event.pointerId, { ...point });
optionsRef.current.onInteraction();
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pending",
pointerId: event.pointerId,
pointerType: event.pointerType,
start: { ...point },
last: { ...point },
lastTime: performance.now(),
origin: currentRef.current,
};
}, [beginPinch, captureGeometry, flushVisual, stopImageAnimation, stopTrackAnimation]);
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const tracked = pointersRef.current.get(event.pointerId);
if (!tracked) return;
event.preventDefault();
pointersRef.current.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
});
const gesture = gestureRef.current;
if (gesture.mode === "pinch") {
const pointers = [...pointersRef.current.values()];
if (pointers.length < 2) return;
const first = pointers[0];
const second = pointers[1];
const midpointNow = midpoint(first, second);
const rawScale = gesture.origin.scale *
(distance(first, second) / Math.max(gesture.pinchDistance, 1));
const maximum = Math.max(1, optionsRef.current.maximumZoom);
const scale = rawScale < 1
? 1 - (1 - rawScale) * 0.22
: rawScale > maximum
? maximum + (rawScale - maximum) * 0.16
: rawScale;
const center = gesture.canvasCenter;
const local = {
x: (gesture.pinchMidpoint.x - center.x - gesture.origin.x) / gesture.origin.scale,
y: (gesture.pinchMidpoint.y - center.y - gesture.origin.y) / gesture.origin.scale,
};
const next = {
x: midpointNow.x - center.x - local.x * scale,
y: midpointNow.y - center.y - local.y * scale,
scale,
};
currentRef.current = next;
targetRef.current = next;
prepareRaster(next);
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
queueVisual({ kind: "motion", motion: next });
return;
}
if (gesture.pointerId !== event.pointerId || gesture.mode === "idle") return;
const point = { x: event.clientX, y: event.clientY };
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const now = performance.now();
const elapsed = Math.max(1, now - gesture.lastTime);
gesture.velocity = {
x: gesture.velocity.x * 0.62 + ((point.x - gesture.last.x) / elapsed) * 0.38,
y: gesture.velocity.y * 0.62 + ((point.y - gesture.last.y) / elapsed) * 0.38,
};
gesture.last = point;
gesture.lastTime = now;
if (gesture.mode === "pending" && magnitude(delta) >= (gesture.pointerType === "mouse" ? 5 : 9)) {
gesture.moved = true;
optionsRef.current.onDragStart();
setInteracting(true);
if (gesture.origin.scale > 1.01) gesture.mode = "pan";
else if (Math.abs(delta.x) > Math.abs(delta.y) * 1.08) gesture.mode = "navigation";
else if (gesture.pointerType === "mouse") gesture.mode = "idle";
else gesture.mode = "dismiss";
}
if (gesture.mode === "navigation") {
let x = delta.x;
if ((x > 0 && !optionsRef.current.canPrevious) || (x < 0 && !optionsRef.current.canNext)) {
x *= 0.22;
}
queueVisual({ kind: "track", motion: { x, y: 0, scale: 1 } });
return;
}
if (gesture.mode === "dismiss") {
const y = delta.y >= 0 ? delta.y * 0.94 : delta.y * 0.16;
const progress = clamp(Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1), 0, 1);
queueVisual({
kind: "track",
motion: { x: delta.x * 0.08, y, scale: 1 - progress * 0.055 },
});
return;
}
if (gesture.mode === "pan") {
const scale = gesture.origin.scale;
const bounds = boundsFor(scale);
const next = {
x: resist(gesture.origin.x + delta.x, bounds.x),
y: resist(gesture.origin.y + delta.y, bounds.y),
scale,
};
currentRef.current = next;
targetRef.current = next;
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
queueVisual({ kind: "motion", motion: next });
}
}, [boundsFor, prepareRaster, queueVisual]);
const finishPointer = useCallback((event: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) => {
const gesture = gestureRef.current;
const tracked = pointersRef.current.has(event.pointerId);
pointersRef.current.delete(event.pointerId);
if (!tracked) return;
flushVisual();
if (gesture.mode === "pinch") {
const remaining = [...pointersRef.current.entries()];
if (remaining.length === 1) {
window.clearTimeout(rasterTimerRef.current);
const [pointerId, point] = remaining[0];
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pan",
pointerId,
pointerType: "touch",
start: { ...point },
last: { ...point },
lastTime: performance.now(),
origin: currentRef.current,
moved: true,
};
return;
}
const target = constrain(currentRef.current);
animateMotion(target, { duration: 220 });
gestureRef.current = { ...EMPTY_GESTURE };
setInteracting(false);
return;
}
if (gesture.pointerId !== event.pointerId) return;
const endPoint = cancelled
? gesture.last
: { x: event.clientX, y: event.clientY };
const delta = {
x: endPoint.x - gesture.start.x,
y: endPoint.y - gesture.start.y,
};
const displayedTrack = trackMotionRef.current;
let keepInteracting = false;
if (cancelled && gesture.mode === "pan") {
animateMotion(constrain(currentRef.current), { duration: 180 });
} else if (cancelled) {
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 180);
} else if (gesture.mode === "navigation") {
const width = geometryRef.current.width;
const direction: -1 | 1 = delta.x > 0 ? -1 : 1;
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
const commit = available &&
(Math.abs(delta.x) > Math.min(120, width * 0.2) || Math.abs(gesture.velocity.x) > 0.55);
if (commit) {
navigatingRef.current = true;
const destination = -direction * pageSpan();
animateTrack(
displayedTrack,
{ x: destination, y: 0, scale: 1 },
230,
() => completeNavigation(direction),
);
} else {
if (!available) optionsRef.current.onEdge(direction);
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 210);
}
} else if (gesture.mode === "dismiss") {
const height = geometryRef.current.height;
const commit = delta.y > Math.min(150, height * 0.18) ||
(delta.y > 50 && gesture.velocity.y > 0.65);
if (commit) {
navigatingRef.current = true;
keepInteracting = true;
animateTrack(
displayedTrack,
{ x: delta.x * 0.12, y: height * 1.08, scale: 0.92 },
240,
optionsRef.current.onDismiss,
);
} else {
animateTrack(
displayedTrack,
{ x: 0, y: 0, scale: 1 },
220,
);
}
} else if (gesture.mode === "pan") {
const inertial = {
x: currentRef.current.x + clamp(gesture.velocity.x * 130, -180, 180),
y: currentRef.current.y + clamp(gesture.velocity.y * 130, -180, 180),
scale: currentRef.current.scale,
};
animateMotion(constrain(inertial), { duration: 260 });
} else if (gesture.mode === "pending" && !gesture.moved) {
optionsRef.current.onTap(endPoint, gesture.pointerType);
}
gestureRef.current = { ...EMPTY_GESTURE };
if (!keepInteracting) setInteracting(false);
}, [animateMotion, animateTrack, completeNavigation, constrain, flushVisual, pageSpan]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
if (optionsRef.current.maximumZoom <= 1.01) return;
optionsRef.current.onInteraction();
stopImageAnimation();
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? event.deltaY * 18
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? event.deltaY * geometryRef.current.height
: event.deltaY;
targetRef.current = currentRef.current;
const base = currentRef.current;
const scale = clamp(
base.scale * Math.exp(-delta * 0.00135),
1,
Math.max(1, optionsRef.current.maximumZoom),
);
const target = zoomAround(base, scale, { x: event.clientX, y: event.clientY });
const motionReduced = reducedMotion();
animateMotion(target, { duration: 90, commitRaster: motionReduced });
if (!motionReduced) scheduleRaster(120);
};
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => canvas.removeEventListener("wheel", handleWheel);
}, [animateMotion, scheduleRaster, stopImageAnimation, zoomAround]);
useEffect(() => {
const update = () => captureGeometry();
window.addEventListener("resize", update);
window.visualViewport?.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, [captureGeometry]);
useLayoutEffect(() => {
captureGeometry();
flushVisual();
stopImageAnimation();
stopTrackAnimation();
window.clearTimeout(rasterTimerRef.current);
window.clearTimeout(navigationUnlockRef.current);
pointersRef.current.clear();
gestureRef.current = { ...EMPTY_GESTURE };
if (layoutImageRef.current !== options.imageId) {
layoutImageRef.current = options.imageId;
currentRef.current = { x: 0, y: 0, scale: 1 };
targetRef.current = { x: 0, y: 0, scale: 1 };
rasterScaleRef.current = 1;
} else {
const motion = constrain(currentRef.current);
currentRef.current = motion;
targetRef.current = motion;
}
navigatingRef.current = false;
applyTrack(0, 0, 1);
commitRaster(currentRef.current);
setInteracting(false);
}, [options.imageId, options.frameSize.height, options.frameSize.width, applyTrack, captureGeometry, commitRaster, constrain, flushVisual, stopImageAnimation, stopTrackAnimation]);
useEffect(() => {
if (currentRef.current.scale <= options.maximumZoom + 0.001) return;
animateMotion(constrain(currentRef.current));
}, [options.maximumZoom, animateMotion, constrain]);
useEffect(() => () => {
window.cancelAnimationFrame(animationRef.current);
window.cancelAnimationFrame(trackAnimationRef.current);
window.cancelAnimationFrame(inputFrameRef.current);
window.clearTimeout(rasterTimerRef.current);
window.clearTimeout(navigationUnlockRef.current);
}, []);
return {
canvasRef,
trackRef,
motionRef,
settledScale,
interacting,
canZoom: options.maximumZoom > 1.01,
handlePointerDown,
handlePointerMove,
handlePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, false),
handlePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
handleLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
navigate,
resetZoom,
toggleZoomAt,
zoomBy,
};
}
export type ViewerEngine = ReturnType<typeof useViewerEngine>;
export type ViewerElementRef = RefObject<HTMLDivElement | null>;
+61 -9
View File
@@ -48,15 +48,11 @@ impl Config {
}
}
if !gallery_dir.is_dir() {
bail!(
"gallery directory does not exist: {}",
gallery_dir.display()
);
}
gallery_dir = canonical_directory(&gallery_dir, "gallery")?;
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("cannot create cache directory: {}", cache_dir.display()))?;
cache_dir = canonical_directory(&cache_dir, "cache")?;
ensure_cache_is_external(&gallery_dir, &cache_dir)?;
Ok(Self {
gallery_dir,
@@ -69,6 +65,27 @@ impl Config {
}
}
fn canonical_directory(path: &std::path::Path, label: &str) -> Result<PathBuf> {
if !path.is_dir() {
bail!("{label} directory does not exist: {}", path.display());
}
std::fs::canonicalize(path)
.with_context(|| format!("cannot resolve {label} directory: {}", path.display()))
}
fn ensure_cache_is_external(
gallery_dir: &std::path::Path,
cache_dir: &std::path::Path,
) -> Result<()> {
if cache_dir.starts_with(gallery_dir) {
bail!(
"cache directory must be outside the gallery directory: {}",
cache_dir.display()
);
}
Ok(())
}
fn value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
args.next()
.with_context(|| format!("{flag} requires a value"))
@@ -115,11 +132,46 @@ fn print_help() {
Usage: pixhelf [OPTIONS]\n\n\
Options:\n \
--gallery-dir PATH Gallery root (default: ./pic)\n \
--cache-dir PATH Thumbnail and viewer preview cache (default: ./.pixhelf-cache/thumbnails)\n \
--cache-dir PATH Thumbnail cache (default: ./.pixhelf-cache/thumbnails)\n \
--listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \
--initial-batch N Thumbnails prepared before serving (default: 60)\n \
--initial-batch N Priority thumbnail batch (default: 60)\n \
--workers N Background thumbnail workers (default: 2-4, based on CPU)\n \
--scan-interval SEC Gallery rescan interval (default: 10)\n \
-h, --help Show this help"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_numeric_ranges() {
let mut valid = ["4".to_owned()].into_iter();
assert_eq!(number_value(&mut valid, "--workers", 1, 16).unwrap(), 4);
let mut invalid = ["0".to_owned()].into_iter();
assert!(number_value(&mut invalid, "--workers", 1, 16).is_err());
}
#[test]
fn resolves_existing_directories() {
let temp = tempfile::tempdir().unwrap();
assert_eq!(
canonical_directory(temp.path(), "test").unwrap(),
temp.path().canonicalize().unwrap()
);
assert!(canonical_directory(&temp.path().join("missing"), "test").is_err());
}
#[test]
fn rejects_cache_directories_inside_the_gallery() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
let nested_cache = gallery.join("cache");
let external_cache = temp.path().join("cache");
assert!(ensure_cache_is_external(&gallery, &nested_cache).is_err());
assert!(ensure_cache_is_external(&gallery, &external_cache).is_ok());
}
}
+83 -30
View File
@@ -6,7 +6,7 @@ use std::{
time::UNIX_EPOCH,
};
use anyhow::{Context, Result};
use anyhow::{Context, Result, anyhow};
use image::{ImageDecoder, ImageReader, metadata::Orientation};
use serde::Serialize;
use tracing::warn;
@@ -37,22 +37,13 @@ pub struct Album {
#[derive(Clone, Default)]
pub struct GalleryIndex {
pub images: Vec<Arc<ImageRecord>>,
pub by_id: HashMap<String, Arc<ImageRecord>>,
pub albums: Vec<Album>,
pub revision: String,
}
impl GalleryIndex {
pub fn get(&self, id: &str) -> Option<Arc<ImageRecord>> {
self.by_id.get(id).cloned()
}
pub fn same_revision(&self, other: &Self) -> bool {
self.images.len() == other.images.len()
&& self
.images
.iter()
.zip(&other.images)
.all(|(left, right)| left.id == right.id)
self.revision == other.revision
}
}
@@ -68,14 +59,17 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
.follow_links(false)
.sort_by_file_name()
.into_iter()
.filter_map(|entry| match entry {
Ok(entry) => Some(entry),
{
let entry = match entry {
Ok(entry) => entry,
Err(error) if previous.is_some() => {
return Err(anyhow!(error).context("gallery rescan was incomplete"));
}
Err(error) => {
warn!(%error, "cannot inspect gallery entry");
None
continue;
}
})
{
};
if !entry.file_type().is_file() || !is_supported_image(entry.path()) {
continue;
}
@@ -84,11 +78,18 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
let relative = path
.strip_prefix(root)
.with_context(|| format!("{} is outside the gallery root", path.display()))?;
let relative_path = path_to_url(relative);
let Some(relative_path) = path_to_url(relative) else {
warn!(path = %path.display(), "image path is not valid UTF-8");
continue;
};
let existing = previous_by_path.get(relative_path.as_str()).copied();
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) => {
warn!(path = %path.display(), %error, "cannot read image metadata");
if let Some(existing) = existing {
images.push(Arc::clone(existing));
}
continue;
}
};
@@ -100,7 +101,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
let modified_ns = modified.as_nanos();
let size = metadata.len();
if let Some(existing) = previous_by_path.get(relative_path.as_str())
if let Some(existing) = existing
&& existing.size == size
&& existing.modified_ns == modified_ns
{
@@ -112,13 +113,16 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
Ok(dimensions) => dimensions,
Err(error) => {
warn!(path = %path.display(), %error, "cannot decode image header");
if let Some(existing) = existing {
images.push(Arc::clone(existing));
}
continue;
}
};
let album = relative
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(path_to_url)
.and_then(path_to_url)
.unwrap_or_default();
let name = path
.file_name()
@@ -144,6 +148,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
images.sort_by(|left, right| {
natord::compare_ignore_case(&left.relative_path, &right.relative_path)
});
let revision = index_revision(&images);
let mut album_counts = BTreeMap::<String, usize>::new();
for image in &images {
@@ -162,18 +167,23 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
count,
})
.collect();
let by_id = images
.iter()
.map(|record| (record.id.clone(), Arc::clone(record)))
.collect();
Ok(GalleryIndex {
images,
by_id,
albums,
revision,
})
}
fn index_revision(images: &[Arc<ImageRecord>]) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"pixhelf-index-v1\0");
for image in images {
hasher.update(image.id.as_bytes());
hasher.update(b"\0");
}
hasher.finalize().to_hex().to_string()
}
fn probe_dimensions(path: &Path) -> Result<(u32, u32)> {
let reader = ImageReader::open(path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
@@ -213,11 +223,11 @@ fn image_id(relative_path: &str, size: u64, modified_ns: u128) -> String {
hasher.finalize().to_hex().to_string()
}
fn path_to_url(path: &Path) -> String {
fn path_to_url(path: &Path) -> Option<String> {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
.map(|component| component.as_os_str().to_str())
.collect::<Option<Vec<_>>>()
.map(|components| components.join("/"))
}
#[cfg(test)]
@@ -256,4 +266,47 @@ mod tests {
assert_eq!((index.images[0].width, index.images[0].height), (20, 10));
assert_eq!(index.albums[0].count, 1);
}
#[test]
fn rescan_keeps_previous_record_while_file_is_invalid() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("image.png");
image::RgbImage::new(20, 10).save(&path).unwrap();
let previous = scan_gallery(temp.path(), None).unwrap();
let previous_id = previous.images[0].id.clone();
fs::write(&path, "incomplete image data").unwrap();
let updated = scan_gallery(temp.path(), Some(&previous)).unwrap();
assert_eq!(updated.images.len(), 1);
assert_eq!(updated.images[0].id, previous_id);
}
#[test]
fn incomplete_rescan_does_not_return_a_partial_index() {
let temp = tempfile::tempdir().unwrap();
let previous = GalleryIndex::default();
let missing = temp.path().join("missing");
assert!(scan_gallery(&missing, Some(&previous)).is_err());
}
#[cfg(unix)]
#[test]
fn skips_non_utf8_paths() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let temp = tempfile::tempdir().unwrap();
image::RgbImage::new(20, 10)
.save(temp.path().join("valid.png"))
.unwrap();
let invalid_name = OsString::from_vec(vec![0xff, b'.', b'p', b'n', b'g']);
image::RgbImage::new(20, 10)
.save(temp.path().join(invalid_name))
.unwrap();
let index = scan_gallery(temp.path(), None).unwrap();
assert_eq!(index.images.len(), 1);
assert_eq!(index.images[0].name, "valid.png");
}
}
+36 -22
View File
@@ -5,12 +5,12 @@ mod web;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use config::Config;
use gallery::scan_gallery;
use thumbs::{ThumbnailManager, ViewerPreviewManager};
use thumbs::ThumbnailManager;
use tokio::{net::TcpListener, sync::RwLock};
use tracing::{error, info};
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;
use web::AppState;
@@ -31,9 +31,9 @@ async fn main() -> Result<()> {
.await
.context("gallery scan task stopped")??;
if index.images.is_empty() {
bail!(
"no supported images found in {}",
config.gallery_dir.display()
warn!(
gallery = %config.gallery_dir.display(),
"gallery is empty; waiting for supported images"
);
}
info!(
@@ -50,11 +50,9 @@ async fn main() -> Result<()> {
.collect();
let index = Arc::new(RwLock::new(index));
let thumbnails = ThumbnailManager::new(config.cache_dir.clone())?;
let previews = ViewerPreviewManager::new(config.cache_dir.clone())?;
{
let current = index.read().await;
thumbnails.reconcile(&current.images);
previews.reconcile(&current.images);
}
thumbnails.start_workers(config.workers);
let initial_thumbnail_manager = Arc::clone(&thumbnails);
@@ -67,16 +65,11 @@ async fn main() -> Result<()> {
let state = AppState {
index: Arc::clone(&index),
thumbnails: Arc::clone(&thumbnails),
previews: Arc::clone(&previews),
};
let cleanup_manager = Arc::clone(&thumbnails);
tokio::spawn(async move {
cleanup_manager.cleanup_stale().await;
});
let preview_cleanup_manager = Arc::clone(&previews);
tokio::spawn(async move {
preview_cleanup_manager.cleanup_stale().await;
});
tokio::spawn(rescan_gallery(state.clone(), config.clone()));
let listener = TcpListener::bind(config.listen)
@@ -95,14 +88,15 @@ async fn rescan_gallery(state: AppState, config: Config) {
tokio::time::sleep(config.scan_interval).await;
let previous = state.index.read().await.clone();
let root = config.gallery_dir.clone();
let previous_for_scan = previous.clone();
let scan =
tokio::task::spawn_blocking(move || scan_gallery(&root, Some(&previous_for_scan)))
.await;
let scan = tokio::task::spawn_blocking(move || {
let updated = scan_gallery(&root, Some(&previous));
(previous, updated)
})
.await;
let updated = match scan {
Ok(Ok(index)) => index,
Ok(Err(error)) => {
let (previous, updated) = match scan {
Ok((previous, Ok(updated))) => (previous, updated),
Ok((_, Err(error))) => {
error!(%error, "gallery rescan failed");
continue;
}
@@ -118,16 +112,36 @@ async fn rescan_gallery(state: AppState, config: Config) {
let old_count = previous.images.len();
let new_count = updated.images.len();
state.thumbnails.reconcile(&updated.images);
state.previews.reconcile(&updated.images);
*state.index.write().await = updated;
state.thumbnails.cleanup_stale().await;
state.previews.cleanup_stale().await;
info!(old_count, new_count, "gallery changes indexed");
}
}
async fn shutdown_signal() {
#[cfg(unix)]
{
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut terminate) => {
tokio::select! {
() = wait_for_ctrl_c() => {}
_ = terminate.recv() => {}
}
}
Err(error) => {
error!(%error, "cannot install SIGTERM handler");
wait_for_ctrl_c().await;
}
}
}
#[cfg(not(unix))]
wait_for_ctrl_c().await;
}
async fn wait_for_ctrl_c() {
if let Err(error) = tokio::signal::ctrl_c().await {
error!(%error, "cannot install shutdown handler");
std::future::pending::<()>().await;
}
}
+286 -326
View File
@@ -1,22 +1,24 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
fs::{self, File},
error::Error,
fmt,
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result, anyhow, bail};
use anyhow::{Context, Result, anyhow};
use image::{
DynamicImage, GenericImageView, ImageDecoder, ImageReader, imageops::FilterType,
metadata::Orientation,
};
use serde::Serialize;
use tokio::sync::{Notify, Semaphore};
use tokio::sync::Notify;
use tracing::{info, warn};
use walkdir::WalkDir;
@@ -28,11 +30,6 @@ const CACHE_VERSION: &str = "720-webp-q82-v1";
const THUMBNAIL_FILTER: FilterType = FilterType::Triangle;
const MAX_ATTEMPTS: u8 = 3;
const PENDING: u8 = 0;
const PROCESSING: u8 = 1;
const READY: u8 = 2;
const FAILED: u8 = 3;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub struct ThumbnailManager {
@@ -42,188 +39,38 @@ pub struct ThumbnailManager {
initial_ready: AtomicBool,
}
const PREVIEW_CACHE_VERSION: &str = "2560-webp-q88-v2";
const PREVIEW_EDGE: u32 = 2560;
const PREVIEW_QUALITY: f32 = 88.0;
const PREVIEW_FILTER: FilterType = FilterType::CatmullRom;
const PREVIEW_PENDING: u8 = 0;
const PREVIEW_PROCESSING: u8 = 1;
const PREVIEW_READY: u8 = 2;
const PREVIEW_FAILED: u8 = 3;
/// Viewer previews are intentionally independent from the eager thumbnail queue.
/// They are generated only when a user opens an image, then retained immutably.
pub struct ViewerPreviewManager {
cache_root: PathBuf,
entries: RwLock<HashMap<String, Arc<PreviewEntry>>>,
slots: Arc<Semaphore>,
#[derive(Debug)]
pub enum ThumbnailError {
NotFound,
Generation(String),
}
struct PreviewEntry {
record: Arc<ImageRecord>,
cache_path: PathBuf,
state: AtomicU8,
last_error: Mutex<Option<String>>,
notify: Notify,
}
impl ViewerPreviewManager {
pub fn new(cache_dir: PathBuf) -> Result<Arc<Self>> {
let cache_root = cache_dir.join(PREVIEW_CACHE_VERSION);
fs::create_dir_all(&cache_root).with_context(|| {
format!(
"cannot create viewer preview cache: {}",
cache_root.display()
)
})?;
purge_temporary_files(&cache_root);
Ok(Arc::new(Self {
cache_root,
entries: RwLock::new(HashMap::new()),
slots: Arc::new(Semaphore::new(2)),
}))
}
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
let mut entries = self.entries.write().expect("viewer preview entries lock");
entries.retain(|id, _| current_ids.contains(id.as_str()));
for record in records {
if entries.contains_key(&record.id) {
continue;
}
let cache_path = shard_path(&self.cache_root, &record.id);
let state = if valid_cache_file(&cache_path) {
PREVIEW_READY
} else {
PREVIEW_PENDING
};
entries.insert(
record.id.clone(),
Arc::new(PreviewEntry {
record: Arc::clone(record),
cache_path,
state: AtomicU8::new(state),
last_error: Mutex::new(None),
notify: Notify::new(),
}),
);
}
}
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
let entry = self
.entries
.read()
.expect("viewer preview entries lock")
.get(id)
.cloned()
.with_context(|| format!("unknown image: {id}"))?;
loop {
let notified = entry.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
match entry.state.load(Ordering::Acquire) {
PREVIEW_READY if valid_cache_file(&entry.cache_path) => {
return Ok(entry.cache_path.clone());
}
PREVIEW_READY => {
let _ = entry.state.compare_exchange(
PREVIEW_READY,
PREVIEW_PENDING,
Ordering::AcqRel,
Ordering::Acquire,
);
}
PREVIEW_PENDING => {
if entry
.state
.compare_exchange(
PREVIEW_PENDING,
PREVIEW_PROCESSING,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
self.start_generation(Arc::clone(&entry));
}
}
PREVIEW_PROCESSING => notified.as_mut().await,
PREVIEW_FAILED => {
let message = entry
.last_error
.lock()
.expect("viewer preview error lock")
.clone()
.unwrap_or_else(|| "viewer preview generation failed".to_owned());
bail!(message);
}
_ => unreachable!(),
}
}
}
fn start_generation(&self, entry: Arc<PreviewEntry>) {
let slots = Arc::clone(&self.slots);
tokio::spawn(async move {
let record = Arc::clone(&entry.record);
let output = entry.cache_path.clone();
let result = async move {
let _permit = slots
.acquire_owned()
.await
.map_err(|_| anyhow!("viewer preview worker stopped"))?;
tokio::task::spawn_blocking(move || generate_preview(&record, &output))
.await
.map_err(|error| anyhow!("viewer preview worker stopped: {error}"))?
}
.await;
match result {
Ok(()) => {
entry.state.store(PREVIEW_READY, Ordering::Release);
entry.notify.notify_waiters();
}
Err(error) => {
*entry.last_error.lock().expect("viewer preview error lock") =
Some(error.to_string());
entry.state.store(PREVIEW_FAILED, Ordering::Release);
entry.notify.notify_waiters();
warn!(image = %entry.record.relative_path, %error, "viewer preview generation failed");
}
}
});
}
pub async fn cleanup_stale(&self) {
let valid_ids: HashSet<String> = self
.entries
.read()
.expect("viewer preview entries lock")
.keys()
.cloned()
.collect();
let root = self.cache_root.clone();
if let Err(error) =
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
{
warn!(%error, "viewer preview cache cleanup task failed");
impl fmt::Display for ThumbnailError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound => formatter.write_str("unknown image"),
Self::Generation(message) => formatter.write_str(message),
}
}
}
impl Error for ThumbnailError {}
struct ThumbEntry {
record: Arc<ImageRecord>,
cache_path: PathBuf,
state: AtomicU8,
urgent: AtomicBool,
attempts: AtomicU8,
last_error: Mutex<Option<String>>,
state: Mutex<ThumbState>,
notify: Notify,
}
enum ThumbState {
Pending { urgent: bool, attempts: u8 },
Processing { urgent: bool, attempts: u8 },
Ready,
Failed(String),
Removed,
}
#[derive(Default)]
struct JobQueue {
state: Mutex<QueueState>,
@@ -237,6 +84,21 @@ struct QueueState {
queued: HashSet<String>,
}
fn mutex_lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn read_lock<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> {
lock.read().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn write_lock<T>(lock: &RwLock<T>) -> RwLockWriteGuard<'_, T> {
lock.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThumbnailStatus {
@@ -267,8 +129,15 @@ impl ThumbnailManager {
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
let mut missing = Vec::new();
{
let mut entries = self.entries.write().expect("thumbnail entries lock");
entries.retain(|id, _| current_ids.contains(id.as_str()));
let mut entries = write_lock(&self.entries);
entries.retain(|id, entry| {
let retained = current_ids.contains(id.as_str());
if !retained {
*mutex_lock(&entry.state) = ThumbState::Removed;
entry.notify.notify_waiters();
}
retained
});
for record in records {
if entries.contains_key(&record.id) {
@@ -276,26 +145,27 @@ impl ThumbnailManager {
}
let cache_path = self.cache_path(&record.id);
let state = if valid_cache_file(&cache_path) {
READY
ThumbState::Ready
} else {
missing.push(record.id.clone());
PENDING
ThumbState::Pending {
urgent: false,
attempts: 0,
}
};
entries.insert(
record.id.clone(),
Arc::new(ThumbEntry {
record: Arc::clone(record),
cache_path,
state: AtomicU8::new(state),
urgent: AtomicBool::new(false),
attempts: AtomicU8::new(0),
last_error: Mutex::new(None),
state: Mutex::new(state),
notify: Notify::new(),
}),
);
}
}
self.queue.retain(&current_ids);
for id in missing {
self.queue.push_background(id);
}
@@ -329,24 +199,25 @@ impl ThumbnailManager {
);
}
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf, ThumbnailError> {
self.promote(id);
self.wait_for(id).await
}
pub fn status(&self) -> ThumbnailStatus {
let entries = self.entries.read().expect("thumbnail entries lock");
let entries = read_lock(&self.entries);
let mut ready = 0;
let mut queued = 0;
let mut processing = 0;
let mut failed = 0;
for entry in entries.values() {
match entry.state.load(Ordering::Acquire) {
READY => ready += 1,
PROCESSING => processing += 1,
FAILED => failed += 1,
_ => queued += 1,
match &*mutex_lock(&entry.state) {
ThumbState::Ready => ready += 1,
ThumbState::Processing { .. } => processing += 1,
ThumbState::Failed(_) => failed += 1,
ThumbState::Pending { .. } => queued += 1,
ThumbState::Removed => {}
}
}
@@ -363,13 +234,7 @@ impl ThumbnailManager {
}
pub async fn cleanup_stale(&self) {
let valid_ids: HashSet<String> = self
.entries
.read()
.expect("thumbnail entries lock")
.keys()
.cloned()
.collect();
let valid_ids: HashSet<String> = read_lock(&self.entries).keys().cloned().collect();
let root = self.cache_root.clone();
if let Err(error) =
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
@@ -379,54 +244,67 @@ impl ThumbnailManager {
}
fn promote(&self, id: &str) {
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
.get(id)
.cloned();
if let Some(entry) = entry
&& entry.state.load(Ordering::Acquire) == PENDING
{
entry.urgent.store(true, Ordering::Release);
let entry = read_lock(&self.entries).get(id).cloned();
let Some(entry) = entry else {
return;
};
let should_queue = {
let mut state = mutex_lock(&entry.state);
match &mut *state {
ThumbState::Pending { urgent, .. } => {
*urgent = true;
true
}
ThumbState::Failed(_) => {
*state = ThumbState::Pending {
urgent: true,
attempts: 0,
};
true
}
_ => false,
}
};
if should_queue {
self.queue.push_urgent(id.to_owned());
}
}
async fn wait_for(&self, id: &str) -> Result<PathBuf> {
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
async fn wait_for(&self, id: &str) -> Result<PathBuf, ThumbnailError> {
let entry = read_lock(&self.entries)
.get(id)
.cloned()
.with_context(|| format!("unknown image: {id}"))?;
.ok_or(ThumbnailError::NotFound)?;
loop {
let notified = entry.notify.notified();
match entry.state.load(Ordering::Acquire) {
READY if valid_cache_file(&entry.cache_path) => {
return Ok(entry.cache_path.clone());
}
READY => {
if entry
.state
.compare_exchange(READY, PENDING, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
self.queue.push_urgent(id.to_owned());
tokio::pin!(notified);
notified.as_mut().enable();
let should_queue = {
let mut state = mutex_lock(&entry.state);
match &*state {
ThumbState::Ready if valid_cache_file(&entry.cache_path) => {
return Ok(entry.cache_path.clone());
}
ThumbState::Ready => {
*state = ThumbState::Pending {
urgent: true,
attempts: 0,
};
true
}
ThumbState::Failed(message) => {
return Err(ThumbnailError::Generation(message.clone()));
}
ThumbState::Removed => return Err(ThumbnailError::NotFound),
ThumbState::Pending { .. } | ThumbState::Processing { .. } => false,
}
FAILED => {
let message = entry
.last_error
.lock()
.expect("thumbnail error lock")
.clone()
.unwrap_or_else(|| "thumbnail generation failed".to_owned());
bail!(message);
}
_ => notified.await,
};
if should_queue {
self.queue.push_urgent(id.to_owned());
} else {
notified.as_mut().await;
}
}
}
@@ -434,21 +312,17 @@ impl ThumbnailManager {
async fn worker_loop(self: Arc<Self>, worker: usize) {
loop {
let id = self.queue.pop().await;
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
.get(&id)
.cloned();
let entry = read_lock(&self.entries).get(&id).cloned();
let Some(entry) = entry else {
continue;
};
if entry
.state
.compare_exchange(PENDING, PROCESSING, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
continue;
let mut state = mutex_lock(&entry.state);
let ThumbState::Pending { urgent, attempts } = &*state else {
continue;
};
let (urgent, attempts) = (*urgent, *attempts);
*state = ThumbState::Processing { urgent, attempts };
}
let record = Arc::clone(&entry.record);
@@ -460,22 +334,50 @@ impl ThumbnailManager {
match result {
Ok(()) => {
entry.state.store(READY, Ordering::Release);
entry.notify.notify_waiters();
let (ready, removed) = {
let mut state = mutex_lock(&entry.state);
if matches!(*state, ThumbState::Processing { .. }) {
*state = ThumbState::Ready;
(true, false)
} else {
(false, matches!(*state, ThumbState::Removed))
}
};
if ready {
entry.notify.notify_waiters();
} else if removed {
let entries = read_lock(&self.entries);
if !entries.contains_key(&id)
&& let Err(error) = fs::remove_file(&entry.cache_path)
&& error.kind() != std::io::ErrorKind::NotFound
{
warn!(path = %entry.cache_path.display(), %error, "cannot remove stale thumbnail");
}
}
}
Err(error) => {
let attempt = entry.attempts.fetch_add(1, Ordering::AcqRel) + 1;
*entry.last_error.lock().expect("thumbnail error lock") =
Some(error.to_string());
let transition = {
let mut state = mutex_lock(&entry.state);
let ThumbState::Processing { urgent, attempts } = &*state else {
continue;
};
let (urgent, attempt) = (*urgent, attempts.saturating_add(1));
let retry = attempt < MAX_ATTEMPTS;
if retry {
*state = ThumbState::Pending {
urgent,
attempts: attempt,
};
} else {
*state = ThumbState::Failed(error.to_string());
}
(attempt, retry)
};
let (attempt, retry) = transition;
warn!(worker, image = %entry.record.relative_path, attempt, %error, "thumbnail generation failed");
if attempt < MAX_ATTEMPTS {
entry.state.store(PENDING, Ordering::Release);
entry.notify.notify_waiters();
entry.notify.notify_waiters();
if retry {
self.schedule_retry(entry, id, attempt);
} else {
entry.state.store(FAILED, Ordering::Release);
entry.notify.notify_waiters();
}
}
}
@@ -491,10 +393,11 @@ impl ThumbnailManager {
tokio::spawn(async move {
let delay = Duration::from_millis(200 * (1 << (attempt - 1)));
tokio::time::sleep(delay).await;
if entry.state.load(Ordering::Acquire) != PENDING {
return;
}
if entry.urgent.load(Ordering::Acquire) {
let urgent = match &*mutex_lock(&entry.state) {
ThumbState::Pending { urgent, .. } => *urgent,
_ => return,
};
if urgent {
manager.queue.push_urgent(id);
} else {
manager.queue.push_background(id);
@@ -504,9 +407,18 @@ impl ThumbnailManager {
}
impl JobQueue {
fn retain(&self, valid_ids: &HashSet<&str>) {
let mut state = mutex_lock(&self.state);
state.urgent.retain(|id| valid_ids.contains(id.as_str()));
state
.background
.retain(|id| valid_ids.contains(id.as_str()));
state.queued.retain(|id| valid_ids.contains(id.as_str()));
}
fn push_urgent(&self, id: String) {
let should_notify = {
let mut state = self.state.lock().expect("thumbnail queue lock");
let mut state = mutex_lock(&self.state);
if state.queued.contains(&id) {
if let Some(position) = state.background.iter().position(|queued| queued == &id) {
state.background.remove(position);
@@ -529,7 +441,7 @@ impl JobQueue {
fn push_background(&self, id: String) {
let should_notify = {
let mut state = self.state.lock().expect("thumbnail queue lock");
let mut state = mutex_lock(&self.state);
if state.queued.insert(id.clone()) {
state.background.push_back(id);
true
@@ -546,8 +458,10 @@ impl JobQueue {
async fn pop(&self) -> String {
loop {
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(id) = {
let mut state = self.state.lock().expect("thumbnail queue lock");
let mut state = mutex_lock(&self.state);
let id = state
.urgent
.pop_front()
@@ -559,7 +473,7 @@ impl JobQueue {
} {
return id;
}
notified.await;
notified.as_mut().await;
}
}
}
@@ -586,17 +500,6 @@ fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> {
)
}
fn generate_preview(record: &ImageRecord, output: &Path) -> Result<()> {
generate_image_cache(
record,
output,
PREVIEW_EDGE,
PREVIEW_FILTER,
PREVIEW_QUALITY,
"viewer preview",
)
}
fn generate_image_cache(
record: &ImageRecord,
output: &Path,
@@ -650,9 +553,12 @@ fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
sequence
));
let write_result = (|| -> Result<()> {
let mut file = File::create(&temporary)?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
file.write_all(encoded.as_ref())?;
file.flush()?;
file.sync_all()?;
drop(file);
fs::rename(&temporary, output)?;
Ok(())
@@ -664,32 +570,38 @@ fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
}
fn cleanup_cache(root: &Path, valid_ids: &HashSet<String>) {
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
if !entry.file_type().is_file()
|| entry.path().extension().and_then(|ext| ext.to_str()) != Some("webp")
{
continue;
visit_cache_files(root, |path| {
if path.extension().and_then(|ext| ext.to_str()) != Some("webp") {
return;
}
let id = entry
.path()
let id = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default();
if !valid_ids.contains(id)
&& let Err(error) = fs::remove_file(entry.path())
&& let Err(error) = fs::remove_file(path)
{
warn!(path = %entry.path().display(), %error, "cannot remove stale thumbnail");
warn!(path = %path.display(), %error, "cannot remove stale thumbnail");
}
}
});
}
fn purge_temporary_files(root: &Path) {
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
if entry.file_type().is_file()
&& entry.path().extension().and_then(|ext| ext.to_str()) == Some("tmp")
&& let Err(error) = fs::remove_file(entry.path())
visit_cache_files(root, |path| {
if path.extension().and_then(|ext| ext.to_str()) == Some("tmp")
&& let Err(error) = fs::remove_file(path)
{
warn!(path = %entry.path().display(), %error, "cannot remove temporary thumbnail");
warn!(path = %path.display(), %error, "cannot remove temporary thumbnail");
}
});
}
fn visit_cache_files(root: &Path, mut visit: impl FnMut(&Path)) {
for entry in WalkDir::new(root).min_depth(1) {
match entry {
Ok(entry) if entry.file_type().is_file() => visit(entry.path()),
Ok(_) => {}
Err(error) => warn!(%error, "cannot inspect thumbnail cache"),
}
}
}
@@ -715,33 +627,6 @@ mod tests {
assert_eq!(generated.dimensions(), (720, 360));
}
#[test]
fn generates_bounded_viewer_preview_without_upscaling() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
fs::create_dir(&gallery).unwrap();
image::RgbImage::new(5000, 2500)
.save(gallery.join("wide.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let output = temp.path().join("preview.webp");
generate_preview(&index.images[0], &output).unwrap();
assert_eq!(image::open(output).unwrap().dimensions(), (2560, 1280));
image::RgbImage::new(400, 200)
.save(gallery.join("small.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let small = index
.images
.iter()
.find(|image| image.name == "small.png")
.unwrap();
let output = temp.path().join("small.webp");
generate_preview(small, &output).unwrap();
assert_eq!(image::open(output).unwrap().dimensions(), (400, 200));
}
#[test]
fn queue_promotes_without_duplicate_jobs() {
let queue = JobQueue::default();
@@ -749,9 +634,84 @@ mod tests {
queue.push_background("image".to_owned());
queue.push_urgent("image".to_owned());
let state = queue.state.lock().unwrap();
let state = mutex_lock(&queue.state);
assert_eq!(state.urgent.as_slices().0, ["image"]);
assert!(state.background.is_empty());
assert_eq!(state.queued.len(), 1);
}
#[tokio::test]
async fn queue_wakes_all_needed_workers() {
let queue = Arc::new(JobQueue::default());
let first_queue = Arc::clone(&queue);
let second_queue = Arc::clone(&queue);
let first = tokio::spawn(async move { first_queue.pop().await });
let second = tokio::spawn(async move { second_queue.pop().await });
tokio::task::yield_now().await;
queue.push_background("first".to_owned());
queue.push_background("second".to_owned());
let first = tokio::time::timeout(Duration::from_secs(1), first)
.await
.expect("first worker timed out")
.unwrap();
let second = tokio::time::timeout(Duration::from_secs(1), second)
.await
.expect("second worker timed out")
.unwrap();
assert_eq!(
HashSet::from([first, second]),
HashSet::from(["first".into(), "second".into()])
);
}
#[tokio::test]
async fn failed_thumbnail_can_be_retried() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
fs::create_dir(&gallery).unwrap();
let image_path = gallery.join("image.png");
image::RgbImage::new(20, 10).save(&image_path).unwrap();
let index = scan_gallery(&gallery, None).unwrap();
fs::write(&image_path, "invalid image").unwrap();
let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap();
manager.reconcile(&index.images);
manager.start_workers(1);
let id = &index.images[0].id;
assert!(matches!(
manager.ensure_ready(id).await,
Err(ThumbnailError::Generation(_))
));
image::RgbImage::new(20, 10).save(&image_path).unwrap();
let thumbnail = manager.ensure_ready(id).await.unwrap();
assert!(valid_cache_file(&thumbnail));
}
#[tokio::test]
async fn removing_an_entry_releases_waiters() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
fs::create_dir(&gallery).unwrap();
image::RgbImage::new(20, 10)
.save(gallery.join("image.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap();
manager.reconcile(&index.images);
let id = index.images[0].id.clone();
let waiting_manager = Arc::clone(&manager);
let waiter = tokio::spawn(async move { waiting_manager.ensure_ready(&id).await });
tokio::task::yield_now().await;
manager.reconcile(&[]);
let result = tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("thumbnail waiter timed out")
.expect("thumbnail waiter stopped");
assert!(matches!(result, Err(ThumbnailError::NotFound)));
}
}
+121 -79
View File
@@ -16,7 +16,7 @@ use tracing::error;
use crate::{
gallery::{Album, GalleryIndex, ImageRecord},
thumbs::{ThumbnailManager, ThumbnailStatus, ViewerPreviewManager},
thumbs::{ThumbnailError, ThumbnailManager, ThumbnailStatus},
};
const INDEX_HTML: &[u8] = include_bytes!(concat!(
@@ -36,7 +36,6 @@ const APP_CSS: &[u8] = include_bytes!(concat!(
pub struct AppState {
pub index: Arc<RwLock<GalleryIndex>>,
pub thumbnails: Arc<ThumbnailManager>,
pub previews: Arc<ViewerPreviewManager>,
}
#[derive(Debug, Default, Deserialize)]
@@ -44,16 +43,29 @@ pub struct AppState {
struct ImagesQuery {
album: Option<String>,
search: Option<String>,
sort: Option<String>,
#[serde(default)]
sort: ImageSort,
seed: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ImageSort {
#[default]
NameAsc,
NameDesc,
Newest,
Explore,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GallerySummary {
total: usize,
albums: Vec<Album>,
revision: String,
}
#[derive(Serialize)]
@@ -78,8 +90,6 @@ struct ImageView {
size: u64,
modified_ms: u64,
thumbnail_url: String,
preview_url: String,
original_url: String,
}
impl From<&ImageRecord> for ImageView {
@@ -94,8 +104,6 @@ impl From<&ImageRecord> for ImageView {
size: record.size,
modified_ms: record.modified_ms,
thumbnail_url: format!("/api/images/{}/thumbnail", record.id),
preview_url: format!("/api/images/{}/preview", record.id),
original_url: format!("/api/images/{}/original", record.id),
}
}
}
@@ -107,8 +115,6 @@ pub fn router(state: AppState) -> Router {
.route("/api/images", get(images))
.route("/api/status", get(thumbnail_status))
.route("/api/images/{id}/thumbnail", get(thumbnail))
.route("/api/images/{id}/preview", get(preview))
.route("/api/images/{id}/original", get(original))
.route("/assets/app.js", get(app_js))
.route("/assets/app.css", get(app_css))
.fallback(frontend)
@@ -126,6 +132,7 @@ async fn gallery_summary(State(state): State<AppState>) -> Json<GallerySummary>
Json(GallerySummary {
total: index.images.len(),
albums: index.albums.clone(),
revision: index.revision.clone(),
})
}
@@ -133,7 +140,7 @@ async fn images(
State(state): State<AppState>,
Query(query): Query<ImagesQuery>,
) -> Json<ImagesResponse> {
let index = state.index.read().await;
let records = state.index.read().await.images.clone();
let search = query
.search
.as_deref()
@@ -142,9 +149,8 @@ async fn images(
.map(str::to_lowercase);
let album = query.album.as_deref().filter(|album| !album.is_empty());
let mut matches: Vec<&Arc<ImageRecord>> = index
.images
.iter()
let mut matches: Vec<Arc<ImageRecord>> = records
.into_iter()
.filter(|record| album.is_none_or(|album| record.album == album))
.filter(|record| {
search
@@ -153,10 +159,15 @@ async fn images(
})
.collect();
match query.sort.as_deref() {
Some("name-desc") => matches.reverse(),
Some("newest") => matches.sort_by_key(|record| Reverse(record.modified_ms)),
_ => {}
match query.sort {
ImageSort::NameAsc => {}
ImageSort::NameDesc => matches.reverse(),
ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)),
ImageSort::Explore => {
let seed_key =
*blake3::hash(query.seed.as_deref().unwrap_or_default().as_bytes()).as_bytes();
matches.sort_by_cached_key(|record| exploration_rank(&seed_key, &record.id));
}
}
let total = matches.len();
@@ -177,6 +188,10 @@ async fn images(
})
}
fn exploration_rank(seed_key: &[u8; 32], image_id: &str) -> [u8; 32] {
*blake3::keyed_hash(seed_key, image_id.as_bytes()).as_bytes()
}
async fn thumbnail_status(State(state): State<AppState>) -> Json<ThumbnailStatus> {
Json(state.thumbnails.status())
}
@@ -186,13 +201,14 @@ async fn thumbnail(
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
if state.index.read().await.get(&id).is_none() {
return Err(StatusCode::NOT_FOUND);
}
let path = state.thumbnails.ensure_ready(&id).await.map_err(|error| {
error!(%id, %error, "cannot serve thumbnail");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let path = match state.thumbnails.ensure_ready(&id).await {
Ok(path) => path,
Err(ThumbnailError::NotFound) => return Err(StatusCode::NOT_FOUND),
Err(error) => {
error!(%id, %error, "cannot serve thumbnail");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
serve_file(
path,
request,
@@ -202,47 +218,6 @@ async fn thumbnail(
.await
}
async fn preview(
State(state): State<AppState>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
if state.index.read().await.get(&id).is_none() {
return Err(StatusCode::NOT_FOUND);
}
let path = state.previews.ensure_ready(&id).await.map_err(|error| {
error!(%id, %error, "cannot serve viewer preview");
StatusCode::INTERNAL_SERVER_ERROR
})?;
serve_file(
path,
request,
&format!("\"preview-{id}\""),
"public, max-age=31536000, immutable",
)
.await
}
async fn original(
State(state): State<AppState>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
let record = state
.index
.read()
.await
.get(&id)
.ok_or(StatusCode::NOT_FOUND)?;
serve_file(
record.path.clone(),
request,
&format!("\"image-{id}\""),
"private, max-age=3600",
)
.await
}
async fn serve_file(
path: std::path::PathBuf,
request: Request,
@@ -291,33 +266,31 @@ fn add_cache_headers(
}
async fn app_js() -> Response {
embedded(APP_JS, "text/javascript; charset=utf-8", true)
embedded(APP_JS, "text/javascript; charset=utf-8")
}
async fn app_css() -> Response {
embedded(APP_CSS, "text/css; charset=utf-8", true)
embedded(APP_CSS, "text/css; charset=utf-8")
}
async fn frontend(request: Request) -> Response {
if request.uri().path() != "/" {
return StatusCode::NOT_FOUND.into_response();
}
if request.method() != Method::GET && request.method() != Method::HEAD {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
embedded(INDEX_HTML, "text/html; charset=utf-8", false)
embedded(INDEX_HTML, "text/html; charset=utf-8")
}
fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool) -> Response {
fn embedded(content: &'static [u8], content_type: &'static str) -> Response {
let mut response = Response::new(Body::from(content));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(if immutable {
"public, max-age=31536000, immutable"
} else {
"no-cache"
}),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
@@ -329,6 +302,22 @@ fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool)
mod tests {
use super::*;
fn empty_router() -> Router {
let temp = tempfile::tempdir().unwrap();
let thumbnails = ThumbnailManager::new(temp.path().to_owned()).unwrap();
router(AppState {
index: Arc::new(RwLock::new(GalleryIndex::default())),
thumbnails,
})
}
async fn request(path: &str) -> Response {
empty_router()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap()
}
#[test]
fn image_view_uses_versioned_id_urls() {
let record = ImageRecord {
@@ -345,7 +334,60 @@ mod tests {
};
let view = ImageView::from(&record);
assert_eq!(view.thumbnail_url, "/api/images/abc/thumbnail");
assert_eq!(view.preview_url, "/api/images/abc/preview");
assert_eq!(view.original_url, "/api/images/abc/original");
}
#[test]
fn exploration_rank_is_stable_and_seeded() {
let first_seed = *blake3::hash(b"first-queue").as_bytes();
let second_seed = *blake3::hash(b"second-queue").as_bytes();
assert_eq!(
exploration_rank(&first_seed, "image-1"),
exploration_rank(&first_seed, "image-1")
);
assert_ne!(
exploration_rank(&first_seed, "image-1"),
exploration_rank(&second_seed, "image-1")
);
assert_ne!(
exploration_rank(&first_seed, "image-1"),
exploration_rank(&first_seed, "image-2")
);
}
#[tokio::test]
async fn rejects_unknown_sort_modes() {
assert_eq!(
request("/api/images?sort=name-asc").await.status(),
StatusCode::OK
);
assert_eq!(
request("/api/images?sort=explore&seed=test").await.status(),
StatusCode::OK
);
assert_eq!(
request("/api/images?sort=unexpected").await.status(),
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn unknown_api_and_thumbnail_routes_return_not_found() {
assert_eq!(
request("/api/does-not-exist").await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
request("/api/images/missing/thumbnail").await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(request("/missing").await.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fixed_asset_names_are_revalidated() {
let response = request("/assets/app.js").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
}
}