Release v0.1.6
This commit is contained in:
1 parent
6c07765759
commit
b07820bb49
9 files changed
+759
-278
No files matched your search
Generated
+1
-1
@@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pixhelf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图
|
||||
|
||||
首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。
|
||||
|
||||
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:提前解码切图窗口内的缩略图并预取相邻原图,切换目标时立即显示对应缩略图,原图下载且解码完成后再无闪烁替换;桌面端会直接复用已就绪的常规原图,移动端切图首帧先使用与视口及 DPR 匹配的高质量位图,当前图片稳定后在后台自动加载真实原图,放大时立即切换,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。
|
||||
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:已经解码的显示资源会直接复用,不再回退到缩略图;桌面端使用原图,移动端在原图像素不超过屏幕目标时也直接使用原图,大图则在当前图片稳定后从中性色缩略图升级到与视口及 DPR 匹配的高质量位图。全分辨率大图只在放大时按需加载,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。缩略图不做额外亮度或饱和度处理,升级时由高质量图层覆盖淡入,避免透明度叠加造成画面先暗后亮。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"preact": "^10.27.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -353,12 +353,6 @@ try {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true"
|
||||
, undefined, { timeout: 60_000 });
|
||||
if (target.name === "mobile") {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")
|
||||
?.getAttribute("data-native-original-loaded") === "true"
|
||||
, undefined, { timeout: 60_000 });
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const originalPaths = performance.getEntriesByType("resource")
|
||||
.map((entry) => new URL(entry.name).pathname)
|
||||
@@ -370,11 +364,15 @@ try {
|
||||
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
|
||||
const viewer = await page.evaluate(() => {
|
||||
const overlay = document.querySelector(".image-viewer");
|
||||
const thumbnail = document.querySelector(".viewer-thumbnail");
|
||||
const original = document.querySelector(".viewer-original");
|
||||
const nativeOriginal = document.querySelector(".viewer-native-original");
|
||||
const media = document.querySelector(".viewer-media")?.getBoundingClientRect();
|
||||
const mediaElement = document.querySelector(".viewer-media");
|
||||
const media = mediaElement?.getBoundingClientRect();
|
||||
const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect();
|
||||
const details = document.querySelector(".viewer-details-page")?.getBoundingClientRect();
|
||||
const thumbnailStyle = thumbnail ? getComputedStyle(thumbnail) : null;
|
||||
const originalStyle = original ? getComputedStyle(original) : null;
|
||||
const resourcePaths = performance.getEntriesByType("resource")
|
||||
.map((entry) => new URL(entry.name).pathname);
|
||||
const originalSource = original instanceof HTMLImageElement
|
||||
@@ -396,11 +394,17 @@ try {
|
||||
nativeOriginalVisible: nativeOriginal
|
||||
? Number.parseFloat(getComputedStyle(nativeOriginal).opacity)
|
||||
: 0,
|
||||
mediaWillChange: document.querySelector(".viewer-media")
|
||||
? getComputedStyle(document.querySelector(".viewer-media")).willChange
|
||||
: "",
|
||||
mediaWillChange: mediaElement ? getComputedStyle(mediaElement).willChange : "",
|
||||
originalPath: originalSource ? new URL(originalSource, location.href).pathname : "",
|
||||
renderer: overlay?.getAttribute("data-renderer"),
|
||||
sourceStrategy: overlay?.getAttribute("data-source-strategy") ?? "",
|
||||
sourcePresentation: overlay?.getAttribute("data-source-presentation") ?? "",
|
||||
thumbnailFilter: thumbnailStyle?.filter ?? "",
|
||||
thumbnailTransform: thumbnailStyle?.transform ?? "",
|
||||
thumbnailTransitionDelay: thumbnailStyle?.transitionDelay ?? "",
|
||||
originalTransitionDuration: originalStyle?.transitionDuration ?? "",
|
||||
gestureRenderer: overlay?.getAttribute("data-mobile-gesture-renderer") ?? "",
|
||||
originalPolicy: overlay?.getAttribute("data-mobile-original-policy") ?? "",
|
||||
scrollMode: overlay?.getAttribute("data-scroll-mode"),
|
||||
renderedPixels: original instanceof HTMLCanvasElement
|
||||
? [original.width, original.height]
|
||||
@@ -473,17 +477,28 @@ try {
|
||||
await page.waitForFunction((imageId) =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId
|
||||
, sideButtonStart.activeId);
|
||||
const sideButtonNavigation = await page.evaluate(({ start, forward, back }) => ({
|
||||
forward,
|
||||
back,
|
||||
returnedId: document.querySelector(".image-viewer")?.getAttribute("data-image-id") ?? "",
|
||||
enabled: document.querySelector(".image-viewer")
|
||||
?.getAttribute("data-mouse-side-navigation") === "true",
|
||||
historyPreserved: history.length === start.historyLength && location.href === start.href,
|
||||
}), { start: sideButtonStart, forward: sideForwardEvents, back: sideBackEvents });
|
||||
await page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
));
|
||||
const sideButtonNavigation = await page.evaluate(({ start, forward, back }) => {
|
||||
const overlay = document.querySelector(".image-viewer");
|
||||
return {
|
||||
forward,
|
||||
back,
|
||||
returnedId: overlay?.getAttribute("data-image-id") ?? "",
|
||||
returnedSource: overlay?.getAttribute("data-display-source") ?? "",
|
||||
returnedPresentation: overlay?.getAttribute("data-source-presentation") ?? "",
|
||||
returnedLoaded: overlay?.getAttribute("data-full-loaded") ?? "false",
|
||||
enabled: overlay?.getAttribute("data-mouse-side-navigation") === "true",
|
||||
historyPreserved: history.length === start.historyLength && location.href === start.href,
|
||||
};
|
||||
}, { start: sideButtonStart, forward: sideForwardEvents, back: sideBackEvents });
|
||||
if (
|
||||
!sideButtonStart.nextId ||
|
||||
sideButtonNavigation.returnedId !== sideButtonStart.activeId ||
|
||||
sideButtonNavigation.returnedPresentation !== "direct" ||
|
||||
sideButtonNavigation.returnedLoaded !== "true" ||
|
||||
!["original", "viewport-bitmap"].includes(sideButtonNavigation.returnedSource) ||
|
||||
!sideButtonNavigation.enabled ||
|
||||
!sideButtonNavigation.historyPreserved ||
|
||||
!Object.values(sideButtonNavigation.forward).every(Boolean) ||
|
||||
@@ -531,9 +546,9 @@ try {
|
||||
switchPerformance.currentId !== switchPerformance.expectedId ||
|
||||
switchPerformance.dispatchDuration > 80 ||
|
||||
switchPerformance.firstFrameDuration > 100 ||
|
||||
switchPerformance.firstFrameSource !== (
|
||||
target.name === "mobile" ? "viewport-bitmap" : "original"
|
||||
) ||
|
||||
(target.name === "mobile"
|
||||
? !["thumbnail", "viewport-bitmap"].includes(switchPerformance.firstFrameSource)
|
||||
: switchPerformance.firstFrameSource !== "original") ||
|
||||
!switchPerformance.focusedOnViewer ||
|
||||
switchPerformance.visibleControlFocusRings !== 0 ||
|
||||
switchPerformance.mediaAnimations !== 0
|
||||
@@ -591,12 +606,6 @@ try {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true"
|
||||
, undefined, { timeout: 60_000 });
|
||||
if (target.name === "mobile") {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")
|
||||
?.getAttribute("data-native-original-loaded") === "true"
|
||||
, undefined, { timeout: 60_000 });
|
||||
}
|
||||
rapidSwitch.upgradedSource = await page.locator(".image-viewer").getAttribute("data-display-source");
|
||||
Object.assign(viewer, { rapidSwitch });
|
||||
if (
|
||||
@@ -834,6 +843,28 @@ try {
|
||||
);
|
||||
viewer.swipeMotionCleaned = true;
|
||||
|
||||
const shortSwipeStartName = await page.locator(".image-viewer")
|
||||
.getAttribute("data-image-name");
|
||||
await dispatchTouch("touchStart", [{ id: 32, x: 240, y: 420 }]);
|
||||
await page.waitForTimeout(100);
|
||||
await dispatchTouch("touchMove", [{ id: 32, x: 200, y: 420 }]);
|
||||
await page.waitForTimeout(100);
|
||||
await dispatchTouch("touchEnd", []);
|
||||
await page.waitForFunction((title) =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title
|
||||
, shortSwipeStartName);
|
||||
const shortSwipeEndName = await page.locator(".image-viewer")
|
||||
.getAttribute("data-image-name");
|
||||
viewer.shortSwipeNavigation = {
|
||||
distance: 40,
|
||||
changed: shortSwipeEndName !== shortSwipeStartName,
|
||||
imageName: shortSwipeEndName,
|
||||
};
|
||||
await page.waitForFunction(() =>
|
||||
!document.querySelector(".viewer-swipe-outgoing")
|
||||
&& !document.querySelector(".image-viewer")?.hasAttribute("data-swipe-direction")
|
||||
);
|
||||
|
||||
await dispatchTouch("touchStart", [{ id: 35, x: 195, y: 500 }]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 350 }]);
|
||||
@@ -1546,6 +1577,15 @@ try {
|
||||
viewer.fullLoaded !== "true" ||
|
||||
!viewer.originalPath.endsWith("/original") ||
|
||||
!["native", "safe-canvas", "viewport-bitmap"].includes(viewer.renderer) ||
|
||||
viewer.sourceStrategy !== (
|
||||
target.name === "mobile" ? "viewport-upgrade" : "direct-original"
|
||||
) ||
|
||||
viewer.thumbnailFilter !== "none" ||
|
||||
viewer.thumbnailTransform !== "none" ||
|
||||
(viewer.sourcePresentation === "upgrade" && (
|
||||
Number.parseFloat(viewer.thumbnailTransitionDelay) < .15 ||
|
||||
Number.parseFloat(viewer.originalTransitionDuration) < .15
|
||||
)) ||
|
||||
viewer.renderedPixels.some((size) => size <= 0) ||
|
||||
(viewer.renderer === "safe-canvas" && viewer.renderedPixels.some((size) => size > 4096)) ||
|
||||
(viewer.renderer === "viewport-bitmap" && viewer.renderedPixels.some((size) => size > 2048)) ||
|
||||
@@ -1582,9 +1622,9 @@ try {
|
||||
viewer.keyboardFocus.visibleControlFocusRings !== 0 ||
|
||||
!viewer.switchPerformance?.focusedOnViewer ||
|
||||
viewer.switchPerformance.visibleControlFocusRings !== 0 ||
|
||||
viewer.switchPerformance.firstFrameSource !== (
|
||||
target.name === "mobile" ? "viewport-bitmap" : "original"
|
||||
) ||
|
||||
(target.name === "mobile"
|
||||
? !["thumbnail", "viewport-bitmap"].includes(viewer.switchPerformance.firstFrameSource)
|
||||
: viewer.switchPerformance.firstFrameSource !== "original") ||
|
||||
viewer.switchPerformance.mediaAnimations !== 0 ||
|
||||
viewer.switchPerformance.dispatchDuration > 80 ||
|
||||
viewer.switchPerformance.firstFrameDuration > 100 ||
|
||||
@@ -1606,13 +1646,15 @@ try {
|
||||
!viewer.returnWheelHandoffLocked
|
||||
)) ||
|
||||
(target.name === "mobile" && (
|
||||
viewer.nativeOriginalLoaded !== "true" ||
|
||||
viewer.nativeOriginalLoaded !== "false" ||
|
||||
viewer.nativeOriginalActive !== "false" ||
|
||||
viewer.nativeOriginalCount !== 1 ||
|
||||
!viewer.nativeOriginalPath.endsWith("/original") ||
|
||||
viewer.nativeOriginalCount !== 0 ||
|
||||
viewer.nativeOriginalPath !== "" ||
|
||||
viewer.nativeOriginalVisible > .01 ||
|
||||
viewer.nativeOriginalPixels.some((size, index) => size <= viewer.renderedPixels[index]) ||
|
||||
viewer.nativeOriginalPixels.some((size) => size !== 0) ||
|
||||
viewer.displaySource !== "viewport-bitmap" ||
|
||||
viewer.gestureRenderer !== "raf-dom" ||
|
||||
viewer.originalPolicy !== "zoom-only" ||
|
||||
viewer.mediaWillChange !== "auto" ||
|
||||
!viewer.pinchZoomed ||
|
||||
viewer.pinchScale < 1.5 ||
|
||||
@@ -1628,6 +1670,8 @@ try {
|
||||
viewer.nativeOriginalAfterReset.settledWillChange !== "auto" ||
|
||||
!viewer.swipeNavigation ||
|
||||
viewer.swipeNavigation === viewer.keyboardNavigation ||
|
||||
!viewer.shortSwipeNavigation?.changed ||
|
||||
viewer.shortSwipeNavigation.distance !== 40 ||
|
||||
viewer.swipeDragTracking > -190 ||
|
||||
viewer.swipeDragTracking < -225 ||
|
||||
viewer.swipeDragWillChange !== "transform" ||
|
||||
@@ -1644,7 +1688,7 @@ try {
|
||||
!viewer.detailsFollowedTouch ||
|
||||
!viewer.detailsReachedBySwipe ||
|
||||
viewer.pageAfterDetails !== "details" ||
|
||||
viewer.detailsName !== viewer.swipeNavigation ||
|
||||
viewer.detailsName !== viewer.shortSwipeNavigation.imageName ||
|
||||
viewer.detailsHeading !== "图片详情" ||
|
||||
!viewer.stageScrolledAway ||
|
||||
!viewer.detailsVisible ||
|
||||
|
||||
+498
-184
File diff suppressed because it is too large.
Load diff
+16
-7
@@ -1144,9 +1144,9 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
.viewer-thumbnail {
|
||||
filter: saturate(.88) brightness(.84) blur(.35px);
|
||||
transform: scale(1.002);
|
||||
transition: filter 160ms ease, opacity 140ms ease;
|
||||
filter: none;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.viewer-original {
|
||||
@@ -1157,7 +1157,7 @@ select:focus-visible,
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease;
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
.viewer-original-canvas { object-fit: fill; }
|
||||
@@ -1175,10 +1175,19 @@ select:focus-visible,
|
||||
|
||||
.image-viewer[data-full-loaded="true"] .viewer-original { opacity: 1; }
|
||||
|
||||
.image-viewer[data-full-loaded="true"] .viewer-thumbnail { opacity: 0; }
|
||||
.image-viewer[data-source-presentation="direct"]
|
||||
.viewer-original:not(.viewer-native-original) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-viewer[data-full-failed="true"] .viewer-thumbnail {
|
||||
filter: saturate(.92) brightness(.9);
|
||||
.image-viewer[data-full-loaded="true"] .viewer-thumbnail {
|
||||
opacity: 0;
|
||||
transition: opacity 0s linear 160ms;
|
||||
}
|
||||
|
||||
.image-viewer[data-source-presentation="direct"][data-full-loaded="true"]
|
||||
.viewer-thumbnail {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.viewer-nav {
|
||||
|
||||
+160
-46
@@ -1,11 +1,13 @@
|
||||
import type { GalleryImage } from "./types";
|
||||
|
||||
const THUMBNAIL_CACHE_LIMIT = 32;
|
||||
const ORIGINAL_CACHE_LIMIT = 8;
|
||||
const ORIGINAL_CACHE_LIMIT = 4;
|
||||
const VIEWPORT_RENDER_CACHE_LIMIT = 4;
|
||||
const THUMBNAIL_PRELOAD_DISTANCE = 10;
|
||||
const ORIGINAL_PRELOAD_DISTANCE = 2;
|
||||
|
||||
type AssetPriority = "high" | "low";
|
||||
|
||||
type NetworkInformation = {
|
||||
effectiveType?: string;
|
||||
saveData?: boolean;
|
||||
@@ -26,12 +28,25 @@ export type ViewerRenderAsset = {
|
||||
status: ViewerAssetStatus;
|
||||
width: number;
|
||||
height: number;
|
||||
priority: AssetPriority;
|
||||
lastUsed: number;
|
||||
};
|
||||
|
||||
type ViewportRenderJob = {
|
||||
asset: ViewerRenderAsset;
|
||||
image: GalleryImage;
|
||||
attempt: number;
|
||||
key: string;
|
||||
controller: AbortController;
|
||||
cancelled: boolean;
|
||||
resolve: () => void;
|
||||
};
|
||||
|
||||
const thumbnailAssets = new Map<string, ViewerImageAsset>();
|
||||
const originalAssets = new Map<string, ViewerImageAsset>();
|
||||
const viewportRenderAssets = new Map<string, ViewerRenderAsset>();
|
||||
const viewportRenderQueue: ViewportRenderJob[] = [];
|
||||
let activeViewportRenderJob: ViewportRenderJob | null = null;
|
||||
let assetClock = 0;
|
||||
|
||||
export function viewerThumbnailUrl(image: GalleryImage): string {
|
||||
@@ -50,6 +65,17 @@ function canSpeculativelyPreloadOriginals(): boolean {
|
||||
return connection?.effectiveType !== "slow-2g" && connection?.effectiveType !== "2g";
|
||||
}
|
||||
|
||||
export function supportsViewerViewportBitmaps(): boolean {
|
||||
if (typeof createImageBitmap !== "function") return false;
|
||||
if (window.innerWidth <= 720) return true;
|
||||
return navigator.maxTouchPoints > 0
|
||||
&& window.matchMedia("(pointer: coarse)").matches;
|
||||
}
|
||||
|
||||
export function canPreloadViewerNeighbors(): boolean {
|
||||
return canSpeculativelyPreloadOriginals();
|
||||
}
|
||||
|
||||
function pruneCache(
|
||||
cache: Map<string, ViewerImageAsset>,
|
||||
limit: number,
|
||||
@@ -69,7 +95,7 @@ function loadImageAsset(
|
||||
cache: Map<string, ViewerImageAsset>,
|
||||
url: string,
|
||||
limit: number,
|
||||
priority: "high" | "low",
|
||||
priority: AssetPriority,
|
||||
): ViewerImageAsset {
|
||||
const cached = cache.get(url);
|
||||
if (cached) {
|
||||
@@ -116,7 +142,7 @@ function loadImageAsset(
|
||||
|
||||
export function preloadViewerThumbnail(
|
||||
image: GalleryImage,
|
||||
priority: "high" | "low" = "low",
|
||||
priority: AssetPriority = "low",
|
||||
): ViewerImageAsset {
|
||||
return loadImageAsset(
|
||||
thumbnailAssets,
|
||||
@@ -133,7 +159,7 @@ export function getViewerThumbnailStatus(image: GalleryImage): ViewerAssetStatus
|
||||
export function getViewerOriginalAsset(
|
||||
image: GalleryImage,
|
||||
attempt = 0,
|
||||
priority: "high" | "low" = "high",
|
||||
priority: AssetPriority = "high",
|
||||
): ViewerImageAsset {
|
||||
return loadImageAsset(
|
||||
originalAssets,
|
||||
@@ -150,13 +176,19 @@ export function getViewerOriginalStatus(
|
||||
return originalAssets.get(viewerOriginalUrl(image, attempt))?.status ?? "idle";
|
||||
}
|
||||
|
||||
function viewportRenderKey(
|
||||
function viewportRenderRequest(
|
||||
image: GalleryImage,
|
||||
width: number,
|
||||
height: number,
|
||||
attempt: number,
|
||||
): string {
|
||||
return `${viewerOriginalUrl(image, attempt)}@${width}x${height}`;
|
||||
): { key: string; width: number; height: number } {
|
||||
const renderWidth = Math.max(1, Math.round(width));
|
||||
const renderHeight = Math.max(1, Math.round(height));
|
||||
return {
|
||||
key: `${viewerOriginalUrl(image, attempt)}@${renderWidth}x${renderHeight}`,
|
||||
width: renderWidth,
|
||||
height: renderHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneViewportRenderCache(protectedKey?: string): void {
|
||||
@@ -171,19 +203,96 @@ function pruneViewportRenderCache(protectedKey?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelViewportRenderJob(job: ViewportRenderJob): void {
|
||||
job.cancelled = true;
|
||||
job.controller.abort();
|
||||
job.asset.status = "failed";
|
||||
if (viewportRenderAssets.get(job.key) === job.asset) {
|
||||
viewportRenderAssets.delete(job.key);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelStaleViewportRenderJobs(activeKey: string): void {
|
||||
for (let index = viewportRenderQueue.length - 1; index >= 0; index -= 1) {
|
||||
const job = viewportRenderQueue[index]!;
|
||||
if (job.key === activeKey) continue;
|
||||
viewportRenderQueue.splice(index, 1);
|
||||
cancelViewportRenderJob(job);
|
||||
job.resolve();
|
||||
}
|
||||
if (activeViewportRenderJob && activeViewportRenderJob.key !== activeKey) {
|
||||
cancelViewportRenderJob(activeViewportRenderJob);
|
||||
}
|
||||
}
|
||||
|
||||
function startNextViewportRenderJob(): void {
|
||||
if (activeViewportRenderJob || viewportRenderQueue.length === 0) return;
|
||||
viewportRenderQueue.sort((first, second) => {
|
||||
if (first.asset.priority !== second.asset.priority) {
|
||||
return first.asset.priority === "high" ? -1 : 1;
|
||||
}
|
||||
return second.asset.lastUsed - first.asset.lastUsed;
|
||||
});
|
||||
const job = viewportRenderQueue.shift()!;
|
||||
activeViewportRenderJob = job;
|
||||
void (async () => {
|
||||
const { asset, attempt, controller, image, key, resolve } = job;
|
||||
try {
|
||||
const requestOptions: RequestInit & { priority: "high" | "low" } = {
|
||||
cache: attempt ? "reload" : "force-cache",
|
||||
credentials: "same-origin",
|
||||
priority: asset.priority,
|
||||
signal: controller.signal,
|
||||
};
|
||||
const response = await fetch(viewerOriginalUrl(image, attempt), requestOptions);
|
||||
if (!response.ok) throw new Error(`original request failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
if (!blob.size) throw new Error("original response is empty");
|
||||
const bitmap = await createImageBitmap(blob, {
|
||||
imageOrientation: "from-image",
|
||||
resizeWidth: asset.width,
|
||||
resizeHeight: asset.height,
|
||||
resizeQuality: "high",
|
||||
});
|
||||
if (job.cancelled) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
asset.bitmap = bitmap;
|
||||
asset.status = "ready";
|
||||
} catch {
|
||||
asset.status = "failed";
|
||||
} finally {
|
||||
if (job.cancelled) {
|
||||
asset.bitmap?.close();
|
||||
asset.bitmap = null;
|
||||
asset.status = "failed";
|
||||
if (viewportRenderAssets.get(key) === asset) viewportRenderAssets.delete(key);
|
||||
}
|
||||
asset.lastUsed = ++assetClock;
|
||||
if (activeViewportRenderJob === job) activeViewportRenderJob = null;
|
||||
resolve();
|
||||
pruneViewportRenderCache(key);
|
||||
startNextViewportRenderJob();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function getViewerViewportRenderAsset(
|
||||
image: GalleryImage,
|
||||
width: number,
|
||||
height: number,
|
||||
attempt = 0,
|
||||
priority: "high" | "low" = "high",
|
||||
priority: AssetPriority = "high",
|
||||
): ViewerRenderAsset {
|
||||
const renderWidth = Math.max(1, Math.round(width));
|
||||
const renderHeight = Math.max(1, Math.round(height));
|
||||
const key = viewportRenderKey(image, renderWidth, renderHeight, attempt);
|
||||
const request = viewportRenderRequest(image, width, height, attempt);
|
||||
const { key } = request;
|
||||
if (priority === "high") cancelStaleViewportRenderJobs(key);
|
||||
const cached = viewportRenderAssets.get(key);
|
||||
if (cached) {
|
||||
cached.lastUsed = ++assetClock;
|
||||
if (priority === "high") cached.priority = "high";
|
||||
startNextViewportRenderJob();
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -194,49 +303,41 @@ export function getViewerViewportRenderAsset(
|
||||
resolveAsset = resolve;
|
||||
}),
|
||||
status: "loading",
|
||||
width: renderWidth,
|
||||
height: renderHeight,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
priority,
|
||||
lastUsed: ++assetClock,
|
||||
};
|
||||
viewportRenderAssets.set(key, asset);
|
||||
const original = getViewerOriginalAsset(image, attempt, priority);
|
||||
void original.promise.then(async () => {
|
||||
try {
|
||||
if (original.status !== "ready" || typeof createImageBitmap !== "function") {
|
||||
asset.status = "failed";
|
||||
return;
|
||||
}
|
||||
asset.bitmap = await createImageBitmap(original.element, {
|
||||
resizeWidth: renderWidth,
|
||||
resizeHeight: renderHeight,
|
||||
resizeQuality: "high",
|
||||
});
|
||||
asset.status = "ready";
|
||||
} catch {
|
||||
asset.status = "failed";
|
||||
} finally {
|
||||
asset.lastUsed = ++assetClock;
|
||||
resolveAsset();
|
||||
pruneViewportRenderCache(key);
|
||||
}
|
||||
// Decode the compressed response straight into the viewport-sized bitmap. Using an
|
||||
// HTMLImageElement here keeps the *full* decoded original alive as well; a single
|
||||
// high-resolution phone photo can otherwise retain hundreds of megabytes while the
|
||||
// user is swiping through neighboring images.
|
||||
viewportRenderQueue.push({
|
||||
asset,
|
||||
image,
|
||||
attempt,
|
||||
key,
|
||||
controller: new AbortController(),
|
||||
cancelled: false,
|
||||
resolve: resolveAsset,
|
||||
});
|
||||
startNextViewportRenderJob();
|
||||
pruneViewportRenderCache(key);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function getViewerViewportRenderStatus(
|
||||
export function getReadyViewerViewportRenderAsset(
|
||||
image: GalleryImage,
|
||||
width: number,
|
||||
height: number,
|
||||
attempt = 0,
|
||||
): ViewerAssetStatus | "idle" {
|
||||
const key = viewportRenderKey(
|
||||
image,
|
||||
Math.max(1, Math.round(width)),
|
||||
Math.max(1, Math.round(height)),
|
||||
attempt,
|
||||
);
|
||||
return viewportRenderAssets.get(key)?.status ?? "idle";
|
||||
): ViewerRenderAsset | null {
|
||||
const { key } = viewportRenderRequest(image, width, height, attempt);
|
||||
const asset = viewportRenderAssets.get(key);
|
||||
if (asset?.status !== "ready" || !asset.bitmap) return null;
|
||||
asset.lastUsed = ++assetClock;
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function getDecodedViewerOriginal(source: string): HTMLImageElement | null {
|
||||
@@ -246,12 +347,21 @@ export function getDecodedViewerOriginal(source: string): HTMLImageElement | nul
|
||||
}
|
||||
|
||||
export function preloadOriginalImage(image: GalleryImage): void {
|
||||
if (!canSpeculativelyPreloadOriginals()) return;
|
||||
if (
|
||||
supportsViewerViewportBitmaps()
|
||||
|| !canSpeculativelyPreloadOriginals()
|
||||
) return;
|
||||
getViewerOriginalAsset(image, 0, "low");
|
||||
}
|
||||
|
||||
export function prepareViewerImages(images: GalleryImage[], activeIndex: number): void {
|
||||
export function prepareViewerImages(
|
||||
images: GalleryImage[],
|
||||
activeIndex: number,
|
||||
options: { preloadOriginals?: boolean } = {},
|
||||
): void {
|
||||
if (activeIndex < 0 || activeIndex >= images.length) return;
|
||||
const preloadOriginals = options.preloadOriginals
|
||||
?? !supportsViewerViewportBitmaps();
|
||||
|
||||
// Thumbnail preparation is deliberately independent from masonry visibility. It is
|
||||
// cheap enough to keep a generous navigation window decoded even on constrained links.
|
||||
@@ -263,8 +373,12 @@ export function prepareViewerImages(images: GalleryImage[], activeIndex: number)
|
||||
}
|
||||
}
|
||||
|
||||
// The active original is always requested. Neighboring originals are speculative and
|
||||
// respect Save-Data/slow-network signals; their thumbnails remain available either way.
|
||||
// Viewport-bitmap clients fetch compressed originals directly into bounded bitmaps.
|
||||
// Avoid also decoding the same originals into hidden HTMLImageElements.
|
||||
if (!preloadOriginals) return;
|
||||
|
||||
// The active desktop original is always requested. Neighboring originals are
|
||||
// speculative and respect Save-Data/slow-network signals.
|
||||
getViewerOriginalAsset(images[activeIndex]!, 0, "high");
|
||||
if (!canSpeculativelyPreloadOriginals()) return;
|
||||
for (let distance = 1; distance <= ORIGINAL_PRELOAD_DISTANCE; distance += 1) {
|
||||
|
||||
Reference in new issue
Block a user