Release v0.1.6

This commit is contained in:
adminroot committed 2026-08-25 06:50:54 +00:00
1 parent 6c07765759
commit b07820bb49
9 files changed
+759 -278

No files matched your search

Generated
+1 -1
View File
@@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pixhelf"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pixhelf"
version = "0.1.5"
version = "0.1.6"
edition = "2024"
build = "build.rs"
+1 -1
View File
@@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图
首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:提前解码切图窗口内的缩略图并预取相邻原图,切换目标时立即显示对应缩略图,原图下载且解码完成后再无闪烁替换;桌面端会直接复用已就绪的常规原图,移动端切图首帧先使用与视口及 DPR 匹配的高质量位图,当前图片稳定后在后台自动加载真实原图,放大时立即切换,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:已经解码的显示资源会直接复用,不再回退到缩略图;桌面端使用原图,移动端在原图像素不超过屏幕目标时也直接使用原图,大图则在当前图片稳定后从中性色缩略图升级到与视口及 DPR 匹配的高质量位图。全分辨率大图只在放大时按需加载,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。缩略图不做额外亮度或饱和度处理,升级时由高质量图层覆盖淡入,避免透明度叠加造成画面先暗后亮。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。
## 构建
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+79 -35
View File
@@ -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 ||
File diff suppressed because it is too large. Load diff
+16 -7
View File
@@ -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
View File
@@ -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) {