mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(media-proxy): serve and request images on one size ladder (#1833)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user