diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index f0f948365..805bf6d17 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -6,14 +6,14 @@ import { downloadAndResize, type DownloadAndResizeOpts, } from '../../src/lib/media/manip' -import {getResizedDimensions} from '../../src/lib/media/util' +import {getResizedDimensions, resolveUploadImageMime, setWebpEncodeSupportForTests} from '../../src/lib/media/util' const mockResizedImage = { path: 'file://resized-image.jpg', size: 100, width: 100, height: 100, - mime: 'image/jpeg', + mime: 'image/webp', } describe('downloadAndResize', () => { @@ -60,11 +60,11 @@ describe('downloadAndResize', () => { // First time it gets called is to get dimensions expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) // The mocked source image is 100x100, below maxDimension, so it is not - // downsized. + // downsized. Quality is binary-searched; assert format + a resize pass. expect(manipulateAsync).toHaveBeenCalledWith( expect.any(String), [{resize: {height: 100, width: 100}}], - {format: SaveFormat.JPEG, compress: 1.0}, + {format: SaveFormat.WEBP, compress: expect.any(Number)}, ) expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { idempotent: true, @@ -136,3 +136,48 @@ describe('downloadAndResize', () => { }) }) }) + +describe('resolveUploadImageMime', () => { + afterEach(() => { + setWebpEncodeSupportForTests(undefined) + }) + + it('forces JPEG for HEIC/HEIF sources even when WebP is requested', () => { + setWebpEncodeSupportForTests(true) + expect(resolveUploadImageMime('image/heic', 'image/webp')).toBe( + 'image/jpeg', + ) + expect(resolveUploadImageMime('image/heif', 'image/webp')).toBe( + 'image/jpeg', + ) + expect(resolveUploadImageMime('image/HEIC', 'image/webp')).toBe( + 'image/jpeg', + ) + expect( + resolveUploadImageMime('image/heic-sequence', 'image/webp'), + ).toBe('image/jpeg') + }) + + it('keeps the requested mime for non-HEIC sources when WebP encode works', () => { + setWebpEncodeSupportForTests(true) + expect(resolveUploadImageMime('image/jpeg', 'image/webp')).toBe( + 'image/webp', + ) + expect(resolveUploadImageMime('image/png', 'image/webp')).toBe( + 'image/webp', + ) + expect(resolveUploadImageMime('image/png', 'image/jpeg')).toBe( + 'image/jpeg', + ) + }) + + it('falls back to JPEG when the environment cannot encode WebP', () => { + setWebpEncodeSupportForTests(false) + expect(resolveUploadImageMime('image/jpeg', 'image/webp')).toBe( + 'image/jpeg', + ) + expect(resolveUploadImageMime('image/png', 'image/webp')).toBe( + 'image/jpeg', + ) + }) +}) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 1240d7c94..e71bd13d5 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -21,7 +21,11 @@ import {logger} from '#/logger' import {IS_ANDROID, IS_IOS} from '#/env' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {getDownloadImageUri, getResizedDimensions} from './util' +import { + getDownloadImageUri, + getResizedDimensions, + resolveUploadImageMime, +} from './util' import {mimeToExt} from './video/util' export async function compressIfNeeded( @@ -29,7 +33,10 @@ export async function compressIfNeeded( {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, opts?: {outputMime?: 'image/jpeg' | 'image/webp'; forceEncode?: boolean}, ): Promise { - const outputMime = opts?.outputMime ?? 'image/jpeg' + const outputMime = resolveUploadImageMime( + img.mime, + opts?.outputMime ?? 'image/jpeg', + ) const needsReencode = opts?.forceEncode || img.size >= maxSize || img.mime !== outputMime diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 104a74e69..74ae27efd 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -3,9 +3,11 @@ import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' import { blobToDataUri, + extractDataUriMime, getDataUriSize, + getDownloadImageUri, getResizedDimensions, - getDownloadImageUri + resolveUploadImageMime, } from './util' import {mimeToExt} from './video/util' @@ -14,7 +16,10 @@ export async function compressIfNeeded( {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, opts?: {outputMime?: 'image/jpeg' | 'image/webp'; forceEncode?: boolean}, ): Promise { - const outputMime = opts?.outputMime ?? 'image/jpeg' + const outputMime = resolveUploadImageMime( + img.mime, + opts?.outputMime ?? 'image/jpeg', + ) const needsReencode = opts?.forceEncode || img.size >= maxSize || img.mime !== outputMime @@ -25,7 +30,7 @@ export async function compressIfNeeded( return await doResize(img.path, { maxDimension, maxSize, - outputMime: opts?.outputMime ?? 'image/jpeg', + outputMime, }) } @@ -116,7 +121,14 @@ async function doResize( const sourceDims = await getImageDim(dataUri) const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension) - const outputMime = opts.outputMime ?? 'image/webp' + /* + * Default WebP, but Safari/iOS can't canvas-encode it — resolve to JPEG + * there so quality binary-search actually shrinks the file. + */ + let outputMime = resolveUploadImageMime( + undefined, + opts.outputMime ?? 'image/webp', + ) let newDataUri let minQualityPercentage = 0 @@ -126,17 +138,28 @@ async function doResize( const qualityPercentage = Math.round( (maxQualityPercentage + minQualityPercentage) / 2, ) - const tempDataUri = await createResizedImage(dataUri, { + const encoded = await createResizedImage(dataUri, { width: newDimensions.width, height: newDimensions.height, quality: qualityPercentage / 100, mode: 'contain', outputMime, }) + /* + * Defense in depth: if the browser ignored WebP and returned PNG, + * switch to JPEG for the rest of the search (PNG ignores `quality`). + */ + if (encoded.mime !== outputMime) { + outputMime = encoded.mime + minQualityPercentage = 0 + maxQualityPercentage = 101 + newDataUri = undefined + continue + } - if (getDataUriSize(tempDataUri) < opts.maxSize) { + if (getDataUriSize(encoded.uri) < opts.maxSize) { minQualityPercentage = qualityPercentage - newDataUri = tempDataUri + newDataUri = encoded.uri } else { maxQualityPercentage = qualityPercentage } @@ -169,7 +192,7 @@ function createResizedImage( mode: 'contain' | 'cover' | 'stretch' outputMime: 'image/jpeg' | 'image/webp' }, -): Promise { +): Promise<{uri: string; mime: 'image/jpeg' | 'image/webp'}> { return new Promise((resolve, reject) => { const img = document.createElement('img') img.addEventListener('load', () => { @@ -192,7 +215,20 @@ function createResizedImage( canvas.height = h ctx.drawImage(img, 0, 0, w, h) - resolve(canvas.toDataURL(outputMime, quality)) + let uri = canvas.toDataURL(outputMime, quality) + let mime: 'image/jpeg' | 'image/webp' = outputMime + /* + * Safari silently falls back to PNG for unsupported WebP encode. + * Re-encode as JPEG so lossy quality control works. + */ + if ( + outputMime === 'image/webp' && + extractDataUriMime(uri) !== 'image/webp' + ) { + uri = canvas.toDataURL('image/jpeg', quality) + mime = 'image/jpeg' + } + resolve({uri, mime}) }) img.addEventListener('error', ev => { reject(ev.error) diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index 1f042c481..641c080d7 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -37,6 +37,81 @@ export function isUriImage(uri: string): boolean { return /\.(jpg|jpeg|png|webp).*$/.test(uri) } +/** + * HEIC/HEIF sources often produce broken or washed-out WebP via + * expo-image-manipulator (HDR / wide-gamut color spaces). Prefer JPEG. + */ +export function isHeicFamilyMime(mime: string | undefined | null): boolean { + if (!mime) return false + switch (mime.toLowerCase()) { + case 'image/heic': + case 'image/heif': + case 'image/heic-sequence': + case 'image/heif-sequence': + return true + default: + return false + } +} + +/* + * Safari / iOS WebKit cannot encode WebP from canvas (`toDataURL` / + * `toBlob`). Requesting `image/webp` silently returns PNG, quality is + * ignored, and our size binary-search never converges → + * "Unable to compress image". Detect real encode support once. + */ +let webpEncodeSupported: boolean | undefined + +function detectWebpEncodeSupport(): boolean { + if (typeof document === 'undefined') { + // Native: expo-image-manipulator encodes WebP without canvas. + return true + } + try { + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + return canvas.toDataURL('image/webp').startsWith('data:image/webp') + } catch { + return false + } +} + +/** + * Whether this environment can lossily encode WebP (canvas on web, native + * manipulator otherwise). Cached after first check. + */ +export function canEncodeWebp(): boolean { + if (webpEncodeSupported === undefined) { + webpEncodeSupported = detectWebpEncodeSupport() + } + return webpEncodeSupported +} + +/** + * Test-only: override / reset cached WebP encode capability. + */ +export function setWebpEncodeSupportForTests(value: boolean | undefined) { + webpEncodeSupported = value +} + +/** + * Pick the upload encode mime. Forces JPEG for HEIC-family inputs and when + * the environment cannot encode WebP (Safari / iOS web). + */ +export function resolveUploadImageMime( + sourceMime: string | undefined | null, + requested: 'image/jpeg' | 'image/webp' = 'image/webp', +): 'image/jpeg' | 'image/webp' { + if (isHeicFamilyMime(sourceMime)) { + return 'image/jpeg' + } + if (requested === 'image/webp' && !canEncodeWebp()) { + return 'image/jpeg' + } + return requested +} + export function blobToDataUri(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 81e0959be..97db2429d 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -19,7 +19,7 @@ import {nanoid} from 'nanoid/non-secure' import {getImageDim} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' -import {getDataUriSize} from '#/lib/media/util' +import {getDataUriSize, resolveUploadImageMime} from '#/lib/media/util' import {isCancelledError} from '#/lib/strings/errors' import {logger} from '#/logger' import {IS_NATIVE, IS_WEB} from '#/env' @@ -295,7 +295,14 @@ export async function compressImage( } const source = img.transformed || img.source - const outputMime = options?.outputMime ?? 'image/webp' + /* + * HEIC/HEIF → WebP via expo-image-manipulator is unreliable (HDR / + * color-space issues). Force JPEG for those sources. + */ + const outputMime = resolveUploadImageMime( + source.mime, + options?.outputMime ?? 'image/webp', + ) const outputFormat = outputMime === 'image/jpeg' ? SaveFormat.JPEG : SaveFormat.WEBP let attempts = 0 diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index e95b461ce..fbb16ea18 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -399,8 +399,6 @@ let EditableUserAvatar = ({ const editImageDialogControl = useDialogControl() const sheetWrapper = useSheetWrapper() - const thumbnailFormat = useThumbnailFormat() - const imageCdnHost = useImageCdnHost() const enableSquareAvatars = useEnableSquareAvatars() @@ -521,12 +519,7 @@ let EditableUserAvatar = ({ ) : ( diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 726eda51d..92cfa45dd 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -26,7 +26,6 @@ import { applyImageTransforms, useImageCdnHost, } from '#/state/preferences/image-cdn-host' -import {useThumbnailFormat} from '#/state/preferences/thumbnail-format' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {EventStopper} from '#/view/com/util/EventStopper' import {atoms as a, tokens, useTheme} from '#/alf' @@ -60,7 +59,6 @@ export function UserBanner({ const [rawImage, setRawImage] = useState() const editImageDialogControl = useDialogControl() const fullsizeFormat = useFullsizeFormat() - const thumbnailFormat = useThumbnailFormat() const imageCdnHost = useImageCdnHost() const onOpenCamera = useCallback(async () => { @@ -145,12 +143,7 @@ export function UserBanner({ () @@ -57,7 +57,11 @@ export function DesktopFeeds() { ) } - if (error || !pinnedFeedInfos) { + /* + * Keep showing cached feeds on refetch errors (same as Home tabs). Checking + * `error` here used to hide the whole sidebar, including "More feeds". + */ + if (!pinnedFeedInfos) { return null }