mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user