diff --git a/fluxer_app/src/features/messaging/utils/MediaProxyRequestSize.ts b/fluxer_app/src/features/messaging/utils/MediaProxyRequestSize.ts new file mode 100644 index 000000000..85e36aa02 --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/MediaProxyRequestSize.ts @@ -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}; +} diff --git a/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts b/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts index bc8a691d0..452fce6d1 100644 --- a/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts +++ b/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts @@ -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 = 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, }); } diff --git a/fluxer_media_proxy/src/constants.rs b/fluxer_media_proxy/src/constants.rs index c7cb26df5..f46b79c9a 100644 --- a/fluxer_media_proxy/src/constants.rs +++ b/fluxer_media_proxy/src/constants.rs @@ -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::() 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()); diff --git a/packages/constants/src/MediaProxyImageSizes.ts b/packages/constants/src/MediaProxyImageSizes.ts index 77f322fff..c0445743d 100644 --- a/packages/constants/src/MediaProxyImageSizes.ts +++ b/packages/constants/src/MediaProxyImageSizes.ts @@ -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 `${infer N extends number}` ? N : never; export type MediaProxyImageSize = ParseNumericLiteral; + +export const MEDIA_PROXY_IMAGE_SIZES: ReadonlyArray = MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES.map( + (value) => Number(value) as MediaProxyImageSize, +);