feat(images): browsing follows media quality on the lightbox and the comic reader

The image detail page served `original=true` to everyone, which is both the largest
image on the site and the reason lossless had little left to sell — anyone could see
the untouched file for free. It now follows the viewer like every other browsing
surface; the download button is what still hands over the stored file.

Width is the SOURCE's own size snapped DOWN, not a fixed large number, because the
cacher upscales. Measured on an 832x1216 original: `width=1600` returns a real
1600x2338 JPEG of 1,017,924 bytes — interpolated pixels carrying no detail the source
had — and even `width=1200` upscales. Snapping down asks for 800: 153,776 bytes
optimized against 2,545,486 for the original, at 4% less linear resolution.
`snapWidthDownToCommonSize` exists for that; `snapWidthToCommonSize` rounds up, which
is right for a layout box and wrong for a source size.

The public comic reader is the other gap, and a larger one than it looks: the chapter
reader, the overview hero and the chapter thumbnails all build URLs with raw
`getEdgeUrl`, which has never resolved the preference, so every panel shipped
unoptimized at width=1200 to every viewer. Resolved at component level rather than in
the render helper, since `renderPanel` is a plain function and cannot hold a hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
manuelurenah
2026-09-15 16:27:31 -04:00
parent 55524dc389
commit 00a1fae7a7
5 changed files with 49 additions and 6 deletions
+2
View File
@@ -15,12 +15,14 @@ import { useMediaQuality } from '~/hooks/useMediaQuality';
// graph). Re-exported here so every existing consumer of this module is unaffected.
export {
COMMON_IMAGE_WIDTHS,
MAX_EDGE_WIDTH,
SRCSET_DPR,
getEdgeUrl,
getEdgeUrlSrcSet,
getInferredMediaType,
resolveOptimized,
resolvesToOriginal,
snapWidthDownToCommonSize,
snapWidthToCommonSize,
toMediaQuality,
} from '~/client-utils/edge-url';
+18
View File
@@ -128,6 +128,24 @@ export function snapWidthToCommonSize(width: number): number {
return width;
}
/**
* Snap a requested width DOWN to the nearest ladder value, never up.
*
* `snapWidthToCommonSize` rounds up, which is right for a layout box — you want at least as many
* pixels as the box. It is wrong when the width is the SOURCE's own size, because the cacher
* upscales: measured on an 832x1216 original, `width=1600` returns a real 1600x2338 JPEG of
* 1,017,924 bytes, interpolated pixels carrying no detail the source did not have. Rounding down
* to 800 asks for 153,776 bytes (optimized) at 4% less linear resolution than the source.
*/
export function snapWidthDownToCommonSize(width: number): number {
let best: number = COMMON_IMAGE_WIDTHS[0];
for (const size of COMMON_IMAGE_WIDTHS) {
if (size === width) return width;
if (size < width) best = size;
}
return Math.min(best, width);
}
/** Ceiling `getEdgeUrl` applies to a requested width, after the ladder snap. */
export const MAX_EDGE_WIDTH = 1800;
+4 -1
View File
@@ -16,6 +16,7 @@ import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getEdgeUrl } from '~/client-utils/cf-images-utils';
import { useMediaQuality } from '~/hooks/useMediaQuality';
import { dialogStore } from '~/components/Dialog/dialogStore';
import { openSetBrowsingLevelModal } from '~/components/Dialog/triggers/set-browsing-level';
import { ImageMetaModal } from '~/components/Post/EditV2/ImageMetaModal';
@@ -124,6 +125,8 @@ export function PanelCard({
onRatingChange,
}: PanelCardProps) {
const { imageUrl, prompt, status, errorMessage } = panel;
// Raw `getEdgeUrl` below, so the viewer's media quality is applied by hand.
const { quality } = useMediaQuality();
const utils = trpc.useUtils();
const features = useFeatureFlags();
@@ -537,7 +540,7 @@ export function PanelCard({
) : imageUrl ? (
<>
<img
src={getEdgeUrl(imageUrl, { width: 450 })}
src={getEdgeUrl(imageUrl, { width: 450, optimized: quality !== 'lossless' })}
alt={prompt}
className={styles.panelImage}
/>
@@ -1,6 +1,7 @@
import { useLocalStorage } from '@mantine/hooks';
import { useState, useEffect, useRef, createContext, useContext } from 'react';
import { EdgeMedia } from '~/components/EdgeMedia/EdgeMedia';
import { MAX_EDGE_WIDTH, snapWidthDownToCommonSize } from '~/client-utils/cf-images-utils';
import { setMediaDragData } from '~/components/EdgeMedia/media-drag-data';
import { ImageStickerOverlay } from '~/components/Sticker/ImageStickerOverlay';
import { shouldDisplayHtmlControls } from '~/components/EdgeMedia/EdgeMedia.util';
@@ -262,6 +263,9 @@ function ImageContent({
});
const isVideo = image?.type === 'video';
// The SOURCE's own width, snapped DOWN and capped. Not the fit box above: `width` there is the
// on-screen box, and asking the cacher for more pixels than the original has only upscales.
const lightboxWidth = snapWidthDownToCommonSize(Math.min(imageWidth, MAX_EDGE_WIDTH));
// dragstart carries no pointerType, and android chrome fires it for a long-press
// drag — leaving that one to embla keeps the touch swipe as `watchTouchDrag` has it
@@ -315,7 +319,11 @@ function ImageContent({
aspectRatio: (image?.width ?? 0) / (image?.height ?? 0),
},
}}
// width={!isVideo ? undefined : 450} // Leave as undefined to get original size
// Browsing follows the viewer's media quality here like anywhere else; the download
// button is what still hands over the stored file. Width is the SOURCE's own size
// snapped DOWN, because the cacher upscales — asking for more than the original has
// buys interpolated pixels at several times the bytes.
width={isVideo ? undefined : lightboxWidth}
// `anim` and `original` feed the CDN URL — an inactive slide has to
// request the same URL the active one will, or it warms nothing
anim
+16 -4
View File
@@ -78,6 +78,7 @@ import { UserAvatarProfilePicture } from '~/components/UserAvatar/UserAvatarProf
import { useBrowsingLevelContext } from '~/components/BrowsingLevel/BrowsingLevelProvider';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { getEdgeUrl } from '~/client-utils/cf-images-utils';
import { useMediaQuality } from '~/hooks/useMediaQuality';
import { ReportEntity } from '~/shared/utils/report-helpers';
import { Flags } from '~/shared/utils/flags';
import {
@@ -201,6 +202,9 @@ function ChapterListItem({
showDownload?: boolean;
onChangeNsfwLevel?: (level: number) => void;
}) {
// Raw `getEdgeUrl` below, so the viewer's media quality is applied by hand.
const { quality } = useMediaQuality();
const compressed = quality !== 'lossless';
const { canRead } = useChapterPermission({
chapterId: ch.id,
projectUserId: project.user.id,
@@ -228,7 +232,7 @@ function ChapterListItem({
{thumbUrl ? (
<>
<img
src={getEdgeUrl(thumbUrl, { width: 120 })}
src={getEdgeUrl(thumbUrl, { width: 120, optimized: compressed })}
alt={ch.name}
className={isBlurred ? styles.chapterThumbBlurred : undefined}
/>
@@ -328,6 +332,10 @@ function ChapterListItem({
function ComicOverview({ project }: { project: Project }) {
const router = useRouter();
// Raw `getEdgeUrl` below, so the viewer's media quality is applied by hand.
const { quality } = useMediaQuality();
const compressed = quality !== 'lossless';
const currentUser = useCurrentUser();
const isOwner = currentUser?.id === project.user.id;
const projectMeta = project.meta as ComicProjectMeta | null;
@@ -419,7 +427,7 @@ function ComicOverview({ project }: { project: Project }) {
safe ? (
<>
<img
src={getEdgeUrl(heroUrl, { width: 1200 })}
src={getEdgeUrl(heroUrl, { width: 1200, optimized: compressed })}
alt={project.name}
className={styles.overviewHeroImage}
style={{ objectPosition: `center ${project.heroImagePosition ?? 50}%` }}
@@ -430,7 +438,7 @@ function ComicOverview({ project }: { project: Project }) {
<>
<div className="absolute inset-0 overflow-hidden">
<img
src={getEdgeUrl(heroUrl, { width: 1200 })}
src={getEdgeUrl(heroUrl, { width: 1200, optimized: compressed })}
alt={project.name}
className={styles.overviewHeroImage}
style={{
@@ -806,6 +814,10 @@ type ReaderMode = 'scroll' | 'pages';
function ChapterReader({ project, chapterDbPos }: { project: Project; chapterDbPos: number }) {
const router = useRouter();
// Raw `getEdgeUrl` below, so the viewer's media quality is applied by hand.
const { quality } = useMediaQuality();
const compressed = quality !== 'lossless';
const currentUser = useCurrentUser();
const availableBuzzTypes = useAvailableBuzz();
const chapters = project.chapters;
@@ -1015,7 +1027,7 @@ function ChapterReader({ project, chapterDbPos }: { project: Project; chapterDbP
// Render a single panel with ImageGuard2 support
const renderPanel = (panel: (typeof panels)[number]) => {
if (!panel.imageUrl) return null;
const panelSrc = getEdgeUrl(panel.imageUrl, { width: 1200 });
const panelSrc = getEdgeUrl(panel.imageUrl, { width: 1200, optimized: compressed });
if (panel.image) {
const image = panel.image;