fix(media-proxy): serve and request images on one size ladder (#1833)

This commit is contained in:
Hampus
2026-08-24 12:27:33 +02:00
committed by GitHub
parent 051985b767
commit 171dc7e5b7
4 changed files with 190 additions and 39 deletions
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {snapMediaProxyImageSize} from '@app/features/messaging/utils/MediaProxyUtils';
export interface ProxyRequestSize {
width: number;
height: number;
}
export function resolveProxyRequestSize(
layoutWidth: number,
layoutHeight: number,
sourceWidth: number,
sourceHeight: number,
): ProxyRequestSize | null {
if (!(sourceWidth > 0) || !(sourceHeight > 0)) return null;
if (!(layoutWidth > 0) || !(layoutHeight > 0)) return null;
const longestLayoutEdge = Math.max(layoutWidth, layoutHeight);
const snappedLongestEdge = snapMediaProxyImageSize(longestLayoutEdge, true);
if (snappedLongestEdge >= Math.max(sourceWidth, sourceHeight)) return null;
const requestScale = snappedLongestEdge / longestLayoutEdge;
const width = Math.min(sourceWidth, Math.max(1, Math.round(layoutWidth * requestScale)));
const height = Math.min(sourceHeight, Math.max(1, Math.round(layoutHeight * requestScale)));
if (width === sourceWidth && height === sourceHeight) return null;
return {width, height};
}
@@ -1,9 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MediaCapabilities, probeMediaCapabilities} from '@app/features/voice/utils/MediaCapabilities';
import {MEDIA_PROXY_IMAGE_SIZES, type MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
if (typeof window !== 'undefined') {
void probeMediaCapabilities();
export const MEDIA_PROXY_IMAGE_SIZE_LADDER = MEDIA_PROXY_IMAGE_SIZES;
export const LARGEST_MEDIA_PROXY_IMAGE_SIZE = MEDIA_PROXY_IMAGE_SIZE_LADDER[MEDIA_PROXY_IMAGE_SIZE_LADDER.length - 1];
const MEDIA_PROXY_DOWNSCALE_TOLERANCE = 1.1;
const MEDIA_PROXY_DOWNSCALE_MIN_DPR = 2;
export function snapMediaProxyImageSize(cssPixels: number, allowDownscale = false): MediaProxyImageSize {
const devicePixelRatio = mediaDevicePixelRatio();
const target = cssPixels * devicePixelRatio;
if (allowDownscale && devicePixelRatio >= MEDIA_PROXY_DOWNSCALE_MIN_DPR) {
let below: MediaProxyImageSize | undefined;
for (const rung of MEDIA_PROXY_IMAGE_SIZE_LADDER) {
if (rung > target) break;
below = rung;
}
if (below !== undefined && target / below <= MEDIA_PROXY_DOWNSCALE_TOLERANCE) return below;
}
return MEDIA_PROXY_IMAGE_SIZE_LADDER.find((rung) => target <= rung) ?? LARGEST_MEDIA_PROXY_IMAGE_SIZE;
}
export interface MediaProxyOptions {
@@ -14,21 +30,16 @@ export interface MediaProxyOptions {
animated?: boolean;
}
const NATIVE_PREFERRED_FORMATS: ReadonlyMap<string, 'avif' | 'jxl'> = new Map([
['image/jxl', 'jxl'],
['image/avif', 'avif'],
]);
export function resolvePreferredImageFormat(sourceContentType?: string): 'webp' | undefined {
if (!sourceContentType) return 'webp';
const normalized = sourceContentType.toLowerCase().split(';')[0]!.trim();
const native = NATIVE_PREFERRED_FORMATS.get(normalized);
if (!native) return 'webp';
const caps = MediaCapabilities.getSync();
if (caps?.[native]) return undefined;
export function resolvePreferredImageFormat(_sourceContentType?: string): 'webp' {
return 'webp';
}
export function mediaDevicePixelRatio(): number {
if (typeof window === 'undefined') return 1;
const ratio = window.devicePixelRatio;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
type FitInsideMediaProxyOptions = MediaProxyOptions & {
width?: number;
height?: number;
@@ -39,25 +50,35 @@ function isSvgProxyUrl(url: URL): boolean {
return path.endsWith('.svg');
}
function resolveProxyDimension(value: number | undefined): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
const pixels = Math.ceil(value);
return pixels > 0 ? pixels : undefined;
}
function appendMediaProxyParams(url: URL, options: MediaProxyOptions): void {
if (isSvgProxyUrl(url)) {
if (url.protocol === 'data:' || url.protocol === 'blob:' || isSvgProxyUrl(url)) {
return;
}
const {width, height, format, quality, animated} = options;
const {format, quality, animated} = options;
const width = resolveProxyDimension(options.width);
const height = resolveProxyDimension(options.height);
if (format) {
url.searchParams.append('format', format);
url.searchParams.set('format', format);
}
if (width !== undefined) {
url.searchParams.append('width', width.toString());
url.searchParams.set('width', width.toString());
}
if (height !== undefined) {
url.searchParams.append('height', height.toString());
url.searchParams.set('height', height.toString());
}
if (quality) {
url.searchParams.append('quality', quality);
url.searchParams.set('quality', quality);
}
if (animated !== undefined) {
url.searchParams.append('animated', animated.toString());
if (animated === true) {
url.searchParams.set('animated', 'true');
} else {
url.searchParams.delete('animated');
}
}
@@ -83,6 +104,29 @@ export function buildFitInsideMediaProxyURL(originalUrl: string, options: FitIns
return url.toString();
}
function readProxyDimensionParam(url: URL, key: 'width' | 'height'): number | undefined {
const raw = url.searchParams.get(key);
if (raw === null) return undefined;
return resolveProxyDimension(Number(raw));
}
function carriedProxyDimensions(proxyURL: string): {width?: number; height?: number} | undefined {
let parsed: URL;
try {
parsed = new URL(proxyURL);
} catch {
return undefined;
}
const width = readProxyDimensionParam(parsed, 'width');
const height = readProxyDimensionParam(parsed, 'height');
if (width === undefined && height === undefined) return undefined;
return {width, height};
}
function variantDimensions(proxyURL: string, width?: number, height?: number): {width?: number; height?: number} {
return carriedProxyDimensions(proxyURL) ?? {width, height};
}
export function stripMediaProxyParams(proxyURL: string): string {
const url = new URL(proxyURL);
url.searchParams.delete('width');
@@ -95,40 +139,44 @@ export function stripMediaProxyParams(proxyURL: string): string {
export function buildAnimatedImageProxyURL(proxyURL: string, width?: number, height?: number): string {
if (!proxyURL) return proxyURL;
const target = variantDimensions(proxyURL, width, height);
const baseURL = stripMediaProxyParams(proxyURL);
return buildMediaProxyURL(baseURL, {
width,
height,
width: target.width,
height: target.height,
animated: true,
});
}
export function buildFittedAnimatedImageProxyURL(proxyURL: string, width?: number, height?: number): string {
if (!proxyURL) return proxyURL;
const target = variantDimensions(proxyURL, width, height);
const baseURL = stripMediaProxyParams(proxyURL);
return buildFitInsideMediaProxyURL(baseURL, {
width,
height,
width: target.width,
height: target.height,
animated: true,
});
}
export function buildStaticGifPreviewURL(proxyURL: string, width?: number, height?: number): string {
if (!proxyURL) return proxyURL;
const target = variantDimensions(proxyURL, width, height);
return buildMediaProxyURL(stripMediaProxyParams(proxyURL), {
format: 'webp',
width,
height,
width: target.width,
height: target.height,
animated: false,
});
}
export function buildFittedStaticGifPreviewURL(proxyURL: string, width?: number, height?: number): string {
if (!proxyURL) return proxyURL;
const target = variantDimensions(proxyURL, width, height);
return buildFitInsideMediaProxyURL(stripMediaProxyParams(proxyURL), {
format: 'webp',
width,
height,
width: target.width,
height: target.height,
animated: false,
});
}
+80 -7
View File
@@ -63,6 +63,15 @@ pub const IMAGE_SIZES: &[u32] = &[
512, 600, 640, 1024, 1280, 1536, 2048, 3072, 4096, 8192, 16384,
];
pub fn snap_to_image_ladder(value: u32) -> u32 {
let largest = IMAGE_SIZES[IMAGE_SIZES.len() - 1];
IMAGE_SIZES
.iter()
.copied()
.find(|rung| value <= *rung)
.unwrap_or(largest)
}
pub fn parse_image_size(raw: Option<&str>) -> u32 {
let Some(text) = raw else {
return DEFAULT_IMAGE_SIZE;
@@ -70,11 +79,7 @@ pub fn parse_image_size(raw: Option<&str>) -> u32 {
let Ok(value) = text.parse::<u32>() else {
return DEFAULT_IMAGE_SIZE;
};
if IMAGE_SIZES.contains(&value) {
value
} else {
DEFAULT_IMAGE_SIZE
}
snap_to_image_ladder(value)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
@@ -182,14 +187,82 @@ pub fn clamp_size(raw_target: u32, kind: AssetKind) -> u32 {
mod tests {
use super::*;
fn asset_cache_key(raw: Option<&str>, kind: AssetKind) -> u32 {
clamp_size(parse_image_size(raw), kind)
}
#[test]
fn image_size_whitelist() {
fn image_size_snaps_up_to_the_next_rung() {
assert_eq!(128, parse_image_size(None));
assert_eq!(640, parse_image_size(Some("640")));
assert_eq!(128, parse_image_size(Some("641")));
assert_eq!(1024, parse_image_size(Some("641")));
assert_eq!(1024, parse_image_size(Some("1000")));
assert_eq!(16, parse_image_size(Some("0")));
assert_eq!(16384, parse_image_size(Some("99999")));
assert_eq!(128, parse_image_size(Some("not-a-number")));
}
#[test]
fn off_ladder_size_never_serves_fewer_pixels_than_requested() {
for requested in 1..=4096u32 {
let served = parse_image_size(Some(&requested.to_string()));
assert!(
served >= requested,
"size={requested} served {served}, a silent downscale"
);
}
}
#[test]
fn every_rung_snaps_to_itself() {
for rung in IMAGE_SIZES {
assert_eq!(*rung, parse_image_size(Some(&rung.to_string())));
}
}
#[test]
fn snapping_is_idempotent() {
for raw in [0u32, 1, 17, 641, 1000, 4097, 99999] {
let once = parse_image_size(Some(&raw.to_string()));
let twice = parse_image_size(Some(&once.to_string()));
assert_eq!(once, twice, "size={raw} did not settle");
}
}
#[test]
fn sub_minimum_sizes_collapse_to_one_avatar_cache_key() {
let canonical = asset_cache_key(Some("128"), AssetKind::Avatar);
assert_eq!(128, canonical);
for below in IMAGE_SIZES.iter().take_while(|rung| **rung < 128) {
assert_eq!(
canonical,
asset_cache_key(Some(&below.to_string()), AssetKind::Avatar),
"size={below} minted a second avatar cache key"
);
}
}
#[test]
fn sub_minimum_sizes_collapse_to_one_banner_cache_key() {
let canonical = asset_cache_key(Some("480"), AssetKind::Banner);
assert_eq!(480, canonical);
for below in IMAGE_SIZES.iter().take_while(|rung| **rung < 480) {
assert_eq!(
canonical,
asset_cache_key(Some(&below.to_string()), AssetKind::Banner),
"size={below} minted a second banner cache key"
);
}
}
#[test]
fn oversize_requests_collapse_onto_the_kind_maximum() {
assert_eq!(1024, asset_cache_key(Some("99999"), AssetKind::Avatar));
assert_eq!(1024, asset_cache_key(Some("1024"), AssetKind::Avatar));
assert_eq!(512, asset_cache_key(Some("99999"), AssetKind::Emoji));
assert_eq!(512, asset_cache_key(Some("99999"), AssetKind::Sticker));
}
#[test]
fn animated_frame_default_allows_dense_short_clips() {
assert_eq!(MAX_ANIMATED_FRAMES_DEFAULT, Limits::animated_frames());
@@ -33,9 +33,13 @@ const MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES = [
'3072',
'4096',
'8192',
'12000',
'16384',
] as const;
export type MediaProxyImageSizeQueryValue = (typeof MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES)[number];
type ParseNumericLiteral<T extends string> = T extends `${infer N extends number}` ? N : never;
export type MediaProxyImageSize = ParseNumericLiteral<MediaProxyImageSizeQueryValue>;
export const MEDIA_PROXY_IMAGE_SIZES: ReadonlyArray<MediaProxyImageSize> = MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES.map(
(value) => Number(value) as MediaProxyImageSize,
);