mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rework scaled dimensions and compression (#737)

* Rework scaled dimensions and compression

* Unbreak image / banner uploads

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Ollie H
Paul Frazee
and committed by
GitHub
072682dd deebe18a

+175 -238
+1
src/lib/api/index.ts
··· 110 110 const images: AppBskyEmbedImages.Image[] = [] 111 111 for (const image of opts.images) { 112 112 opts.onStateChange?.(`Uploading image #${images.length + 1}...`) 113 + await image.compress() 113 114 const path = image.compressed?.path ?? image.path 114 115 const res = await uploadBlob(store, path, 'image/jpeg') 115 116 images.push({
-44
src/lib/media/manip.ts
··· 6 6 import uuid from 'react-native-uuid' 7 7 import * as Sharing from 'expo-sharing' 8 8 import {Dimensions} from './types' 9 - import {POST_IMG_MAX} from 'lib/constants' 10 9 import {isAndroid, isIOS} from 'platform/detection' 11 - 12 - export async function compressAndResizeImageForPost( 13 - image: Image, 14 - ): Promise<Image> { 15 - const uri = `file://${image.path}` 16 - let resized: Omit<Image, 'mime'> 17 - 18 - for (let i = 0; i < 9; i++) { 19 - const quality = 100 - i * 10 20 - 21 - try { 22 - resized = await ImageResizer.createResizedImage( 23 - uri, 24 - POST_IMG_MAX.width, 25 - POST_IMG_MAX.height, 26 - 'JPEG', 27 - quality, 28 - undefined, 29 - undefined, 30 - undefined, 31 - {mode: 'cover'}, 32 - ) 33 - } catch (err) { 34 - throw new Error(`Failed to resize: ${err}`) 35 - } 36 - 37 - if (resized.size < POST_IMG_MAX.size) { 38 - const path = await moveToPermanentPath(resized.path) 39 - 40 - return { 41 - path, 42 - mime: 'image/jpeg', 43 - size: resized.size, 44 - height: resized.height, 45 - width: resized.width, 46 - } 47 - } 48 - } 49 - 50 - throw new Error( 51 - `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`, 52 - ) 53 - } 54 10 55 11 export async function compressIfNeeded( 56 12 img: Image,
-19
src/lib/media/manip.web.ts
··· 1 1 import {Dimensions} from './types' 2 2 import {Image as RNImage} from 'react-native-image-crop-picker' 3 3 import {getDataUriSize, blobToDataUri} from './util' 4 - import {POST_IMG_MAX} from 'lib/constants' 5 - 6 - export async function compressAndResizeImageForPost({ 7 - path, 8 - width, 9 - height, 10 - }: { 11 - path: string 12 - width: number 13 - height: number 14 - }): Promise<RNImage> { 15 - // Compression is handled in `doResize` via `quality` 16 - return await doResize(path, { 17 - width, 18 - height, 19 - maxSize: POST_IMG_MAX.size, 20 - mode: 'stretch', 21 - }) 22 - } 23 4 24 5 export async function compressIfNeeded( 25 6 img: RNImage,
+2 -2
src/lib/media/picker.e2e.tsx
··· 2 2 import {Image as RNImage} from 'react-native-image-crop-picker' 3 3 import RNFS from 'react-native-fs' 4 4 import {CropperOptions} from './types' 5 - import {compressAndResizeImageForPost} from './manip' 5 + import {compressIfNeeded} from './manip' 6 6 7 7 let _imageCounter = 0 8 8 async function getFile() { ··· 13 13 .join('/'), 14 14 ) 15 15 const file = files[_imageCounter++ % files.length] 16 - return await compressAndResizeImageForPost({ 16 + return await compressIfNeeded({ 17 17 path: file.path, 18 18 mime: 'image/jpeg', 19 19 size: file.size,
-17
src/lib/media/util.ts
··· 1 - import {Dimensions} from './types' 2 - 3 1 export function extractDataUriMime(uri: string): string { 4 2 return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) 5 3 } ··· 8 6 // than decoding and checking length of URI 9 7 export function getDataUriSize(uri: string): number { 10 8 return Math.round((uri.length * 3) / 4) 11 - } 12 - 13 - export function scaleDownDimensions( 14 - dim: Dimensions, 15 - max: Dimensions, 16 - ): Dimensions { 17 - if (dim.width < max.width && dim.height < max.height) { 18 - return dim 19 - } 20 - const wScale = dim.width > max.width ? max.width / dim.width : 1 21 - const hScale = dim.height > max.height ? max.height / dim.height : 1 22 - if (wScale < hScale) { 23 - return {width: dim.width * wScale, height: dim.height * wScale} 24 - } 25 - return {width: dim.width * hScale, height: dim.height * hScale} 26 9 } 27 10 28 11 export function isUriImage(uri: string) {
+1
src/state/models/cache/image-sizes.ts
··· 16 16 if (Dimensions) { 17 17 return Dimensions 18 18 } 19 + 19 20 const prom = 20 21 this.activeRequests.get(uri) || 21 22 new Promise<Dimensions>(resolve => {
+5 -19
src/state/models/media/gallery.ts
··· 4 4 import {Image as RNImage} from 'react-native-image-crop-picker' 5 5 import {openPicker} from 'lib/media/picker' 6 6 import {getImageDim} from 'lib/media/manip' 7 - import {getDataUriSize} from 'lib/media/util' 8 7 import {isNative} from 'platform/detection' 9 8 10 9 export class GalleryModel { ··· 24 23 return this.images.length 25 24 } 26 25 27 - get paths() { 28 - return this.images.map(image => 29 - image.compressed === undefined ? image.path : image.compressed.path, 30 - ) 31 - } 32 - 33 - async add(image_: RNImage) { 26 + async add(image_: Omit<RNImage, 'size'>) { 34 27 if (this.size >= 4) { 35 28 return 36 29 } ··· 39 32 if (!this.images.some(i => i.path === image_.path)) { 40 33 const image = new ImageModel(this.rootStore, image_) 41 34 42 - if (!isNative) { 43 - await image.manipulate({}) 44 - } else { 45 - await image.compress() 46 - } 47 - 48 - runInAction(() => { 49 - this.images.push(image) 50 - }) 35 + // Initial resize 36 + image.manipulate({}) 37 + this.images.push(image) 51 38 } 52 39 } 53 40 ··· 70 57 71 58 const {width, height} = await getImageDim(uri) 72 59 73 - const image: RNImage = { 60 + const image = { 74 61 path: uri, 75 62 height, 76 63 width, 77 - size: getDataUriSize(uri), 78 64 mime: 'image/jpeg', 79 65 } 80 66
+108 -69
src/state/models/media/image.ts
··· 3 3 import {makeAutoObservable, runInAction} from 'mobx' 4 4 import {POST_IMG_MAX} from 'lib/constants' 5 5 import * as ImageManipulator from 'expo-image-manipulator' 6 - import {getDataUriSize, scaleDownDimensions} from 'lib/media/util' 6 + import {getDataUriSize} from 'lib/media/util' 7 7 import {openCropper} from 'lib/media/picker' 8 8 import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' 9 9 import {Position} from 'react-avatar-editor' 10 - import {compressAndResizeImageForPost} from 'lib/media/manip' 11 - 12 - // TODO: EXIF embed 13 - // Cases to consider: ExternalEmbed 10 + import {Dimensions} from 'lib/media/types' 14 11 15 12 export interface ImageManipulationAttributes { 16 13 aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' ··· 21 18 flipVertical?: boolean 22 19 } 23 20 24 - export class ImageModel implements RNImage { 21 + const MAX_IMAGE_SIZE_IN_BYTES = 976560 22 + 23 + export class ImageModel implements Omit<RNImage, 'size'> { 25 24 path: string 26 25 mime = 'image/jpeg' 27 26 width: number 28 27 height: number 29 - size: number 30 28 altText = '' 31 29 cropped?: RNImage = undefined 32 30 compressed?: RNImage = undefined 33 - scaledWidth: number = POST_IMG_MAX.width 34 - scaledHeight: number = POST_IMG_MAX.height 35 31 36 32 // Web manipulation 37 33 prev?: RNImage ··· 44 40 } 45 41 prevAttributes: ImageManipulationAttributes = {} 46 42 47 - constructor(public rootStore: RootStoreModel, image: RNImage) { 43 + constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) { 48 44 makeAutoObservable(this, { 49 45 rootStore: false, 50 46 }) ··· 52 48 this.path = image.path 53 49 this.width = image.width 54 50 this.height = image.height 55 - this.size = image.size 56 - this.calcScaledDimensions() 57 51 } 58 52 59 - // TODO: Revisit compression factor due to updated sizing with zoom 60 - // get compressionFactor() { 61 - // const MAX_IMAGE_SIZE_IN_BYTES = 976560 62 - 63 - // return this.size < MAX_IMAGE_SIZE_IN_BYTES 64 - // ? 1 65 - // : MAX_IMAGE_SIZE_IN_BYTES / this.size 66 - // } 67 - 68 53 setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { 69 54 this.attributes.aspectRatio = aspectRatio 70 55 } ··· 93 78 } 94 79 } 95 80 96 - getDisplayDimensions( 97 - as: ImageManipulationAttributes['aspectRatio'] = '1:1', 81 + getUploadDimensions( 82 + dimensions: Dimensions, 83 + maxDimensions: Dimensions = POST_IMG_MAX, 84 + as: ImageManipulationAttributes['aspectRatio'] = 'None', 85 + ) { 86 + const {width, height} = dimensions 87 + const {width: maxWidth, height: maxHeight} = maxDimensions 88 + 89 + return width < maxWidth && height < maxHeight 90 + ? { 91 + width, 92 + height, 93 + } 94 + : this.getResizedDimensions(as, POST_IMG_MAX.width) 95 + } 96 + 97 + getResizedDimensions( 98 + as: ImageManipulationAttributes['aspectRatio'] = 'None', 98 99 maxSide: number, 99 100 ) { 100 101 const ratioMultiplier = this.ratioMultipliers[as] ··· 119 120 } 120 121 } 121 122 122 - calcScaledDimensions() { 123 - const {width, height} = scaleDownDimensions( 124 - {width: this.width, height: this.height}, 125 - POST_IMG_MAX, 126 - ) 127 - this.scaledWidth = width 128 - this.scaledHeight = height 123 + async setAltText(altText: string) { 124 + this.altText = altText 129 125 } 130 126 131 - async setAltText(altText: string) { 132 - this.altText = altText 127 + // Only compress prior to upload 128 + async compress() { 129 + for (let i = 10; i > 0; i--) { 130 + // Float precision 131 + const factor = Math.round(i) / 10 132 + const compressed = await ImageManipulator.manipulateAsync( 133 + this.cropped?.path ?? this.path, 134 + undefined, 135 + { 136 + compress: factor, 137 + base64: true, 138 + format: SaveFormat.JPEG, 139 + }, 140 + ) 141 + 142 + if (compressed.base64 !== undefined) { 143 + const size = getDataUriSize(compressed.base64) 144 + 145 + if (size < MAX_IMAGE_SIZE_IN_BYTES) { 146 + runInAction(() => { 147 + this.compressed = { 148 + mime: 'image/jpeg', 149 + path: compressed.uri, 150 + size, 151 + ...compressed, 152 + } 153 + }) 154 + return 155 + } 156 + } 157 + } 158 + 159 + // Compression fails when removing redundant information is not possible. 160 + // This can be tested with images that have high variance in noise. 161 + throw new Error('Failed to compress image') 133 162 } 134 163 135 - // Only for mobile 164 + // Mobile 136 165 async crop() { 137 166 try { 167 + // openCropper requires an output width and height hence 168 + // getting upload dimensions before cropping is necessary. 169 + const {width, height} = this.getUploadDimensions({ 170 + width: this.width, 171 + height: this.height, 172 + }) 173 + 138 174 const cropped = await openCropper(this.rootStore, { 139 175 mediaType: 'photo', 140 176 path: this.path, 141 177 freeStyleCropEnabled: true, 142 - width: this.scaledWidth, 143 - height: this.scaledHeight, 144 - }) 145 - runInAction(() => { 146 - this.cropped = cropped 147 - this.compress() 148 - }) 149 - } catch (err) { 150 - this.rootStore.log.error('Failed to crop photo', err) 151 - } 152 - } 153 - 154 - async compress() { 155 - try { 156 - const {width, height} = scaleDownDimensions( 157 - this.cropped 158 - ? {width: this.cropped.width, height: this.cropped.height} 159 - : {width: this.width, height: this.height}, 160 - POST_IMG_MAX, 161 - ) 162 - 163 - // TODO: Revisit this - currently iOS uses this as well 164 - const compressed = await compressAndResizeImageForPost({ 165 - ...(this.cropped === undefined ? this : this.cropped), 166 178 width, 167 179 height, 168 180 }) 169 181 170 182 runInAction(() => { 171 - this.compressed = compressed 183 + this.cropped = cropped 172 184 }) 173 185 } catch (err) { 174 - this.rootStore.log.error('Failed to compress photo', err) 186 + this.rootStore.log.error('Failed to crop photo', err) 175 187 } 176 188 } 177 189 ··· 181 193 crop?: ActionCrop['crop'] 182 194 } & ImageManipulationAttributes, 183 195 ) { 196 + let uploadWidth: number | undefined 197 + let uploadHeight: number | undefined 198 + 184 199 const {aspectRatio, crop, position, scale} = attributes 185 200 const modifiers = [] 186 201 ··· 197 212 } 198 213 199 214 if (crop !== undefined) { 215 + const croppedHeight = crop.height * this.height 216 + const croppedWidth = crop.width * this.width 200 217 modifiers.push({ 201 218 crop: { 202 219 originX: crop.originX * this.width, 203 220 originY: crop.originY * this.height, 204 - height: crop.height * this.height, 205 - width: crop.width * this.width, 221 + height: croppedHeight, 222 + width: croppedWidth, 206 223 }, 207 224 }) 225 + 226 + const uploadDimensions = this.getUploadDimensions( 227 + {width: croppedWidth, height: croppedHeight}, 228 + POST_IMG_MAX, 229 + aspectRatio, 230 + ) 231 + 232 + uploadWidth = uploadDimensions.width 233 + uploadHeight = uploadDimensions.height 234 + } else { 235 + const uploadDimensions = this.getUploadDimensions( 236 + {width: this.width, height: this.height}, 237 + POST_IMG_MAX, 238 + aspectRatio, 239 + ) 240 + 241 + uploadWidth = uploadDimensions.width 242 + uploadHeight = uploadDimensions.height 208 243 } 209 244 210 245 if (scale !== undefined) { ··· 222 257 const ratioMultiplier = 223 258 this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] 224 259 225 - const MAX_SIDE = 2000 226 - 227 260 const result = await ImageManipulator.manipulateAsync( 228 261 this.path, 229 262 [ 230 263 ...modifiers, 231 - {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, 264 + { 265 + resize: 266 + ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, 267 + }, 232 268 ], 233 269 { 234 - compress: 0.9, 270 + base64: true, 235 271 format: SaveFormat.JPEG, 236 272 }, 237 273 ) 238 274 239 275 runInAction(() => { 240 - this.compressed = { 276 + this.cropped = { 241 277 mime: 'image/jpeg', 242 278 path: result.uri, 243 - size: getDataUriSize(result.uri), 279 + size: 280 + result.base64 !== undefined 281 + ? getDataUriSize(result.base64) 282 + : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails 244 283 ...result, 245 284 } 246 285 }) 247 286 } 248 287 249 - resetCompressed() { 288 + resetCropped() { 250 289 this.manipulate({}) 251 290 } 252 291 253 292 previous() { 254 - this.compressed = this.prev 293 + this.cropped = this.prev 255 294 this.attributes = this.prevAttributes 256 295 } 257 296 }
+48 -50
src/view/com/composer/photos/Gallery.tsx
··· 104 104 105 105 return !gallery.isEmpty ? ( 106 106 <View testID="selectedPhotosView" style={styles.gallery}> 107 - {gallery.images.map(image => 108 - image.compressed !== undefined ? ( 109 - <View key={`selected-image-${image.path}`} style={[imageStyle]}> 107 + {gallery.images.map(image => ( 108 + <View key={`selected-image-${image.path}`} style={[imageStyle]}> 109 + <TouchableOpacity 110 + testID="altTextButton" 111 + accessibilityRole="button" 112 + accessibilityLabel="Add alt text" 113 + accessibilityHint="" 114 + onPress={() => { 115 + handleAddImageAltText(image) 116 + }} 117 + style={imageControlLabelStyle}> 118 + <Text style={styles.imageControlTextContent}>ALT</Text> 119 + </TouchableOpacity> 120 + <View style={imageControlsSubgroupStyle}> 110 121 <TouchableOpacity 111 - testID="altTextButton" 122 + testID="editPhotoButton" 112 123 accessibilityRole="button" 113 - accessibilityLabel="Add alt text" 124 + accessibilityLabel="Edit image" 114 125 accessibilityHint="" 115 126 onPress={() => { 116 - handleAddImageAltText(image) 127 + handleEditPhoto(image) 117 128 }} 118 - style={imageControlLabelStyle}> 119 - <Text style={styles.imageControlTextContent}>ALT</Text> 129 + style={styles.imageControl}> 130 + <FontAwesomeIcon 131 + icon="pen" 132 + size={12} 133 + style={{color: colors.white}} 134 + /> 120 135 </TouchableOpacity> 121 - <View style={imageControlsSubgroupStyle}> 122 - <TouchableOpacity 123 - testID="editPhotoButton" 124 - accessibilityRole="button" 125 - accessibilityLabel="Edit image" 126 - accessibilityHint="" 127 - onPress={() => { 128 - handleEditPhoto(image) 129 - }} 130 - style={styles.imageControl}> 131 - <FontAwesomeIcon 132 - icon="pen" 133 - size={12} 134 - style={{color: colors.white}} 135 - /> 136 - </TouchableOpacity> 137 - <TouchableOpacity 138 - testID="removePhotoButton" 139 - accessibilityRole="button" 140 - accessibilityLabel="Remove image" 141 - accessibilityHint="" 142 - onPress={() => handleRemovePhoto(image)} 143 - style={styles.imageControl}> 144 - <FontAwesomeIcon 145 - icon="xmark" 146 - size={16} 147 - style={{color: colors.white}} 148 - /> 149 - </TouchableOpacity> 150 - </View> 136 + <TouchableOpacity 137 + testID="removePhotoButton" 138 + accessibilityRole="button" 139 + accessibilityLabel="Remove image" 140 + accessibilityHint="" 141 + onPress={() => handleRemovePhoto(image)} 142 + style={styles.imageControl}> 143 + <FontAwesomeIcon 144 + icon="xmark" 145 + size={16} 146 + style={{color: colors.white}} 147 + /> 148 + </TouchableOpacity> 149 + </View> 151 150 152 - <Image 153 - testID="selectedPhotoImage" 154 - style={[styles.image, imageStyle] as ImageStyle} 155 - source={{ 156 - uri: image.compressed.path, 157 - }} 158 - accessible={true} 159 - accessibilityIgnoresInvertColors 160 - /> 161 - </View> 162 - ) : null, 163 - )} 151 + <Image 152 + testID="selectedPhotoImage" 153 + style={[styles.image, imageStyle] as ImageStyle} 154 + source={{ 155 + uri: image.cropped?.path ?? image.path, 156 + }} 157 + accessible={true} 158 + accessibilityIgnoresInvertColors 159 + /> 160 + </View> 161 + ))} 164 162 </View> 165 163 ) : null 166 164 })
+6 -7
src/view/com/modals/EditImage.tsx
··· 118 118 ) 119 119 120 120 useEffect(() => { 121 - image.prev = image.compressed 121 + image.prev = image.cropped 122 122 image.prevAttributes = image.attributes 123 - image.resetCompressed() 123 + image.resetCropped() 124 124 }, [image]) 125 125 126 126 const onCloseModal = useCallback(() => { ··· 152 152 : {}), 153 153 }) 154 154 155 - image.prev = image.compressed 155 + image.prev = image.cropped 156 156 image.prevAttributes = image.attributes 157 157 onCloseModal() 158 158 }, [altText, image, position, scale, onCloseModal]) ··· 168 168 } 169 169 }, []) 170 170 171 - // Prevents preliminary flash when transformations are being applied 172 - if (image.compressed === undefined) { 171 + if (image.cropped === undefined) { 173 172 return null 174 173 } 175 174 ··· 177 176 windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 178 177 const sideLength = isDesktopWeb ? 300 : computedWidth 179 178 180 - const dimensions = image.getDisplayDimensions(aspectRatio, sideLength) 179 + const dimensions = image.getResizedDimensions(aspectRatio, sideLength) 181 180 const imgContainerStyles = {width: sideLength, height: sideLength} 182 181 183 182 const imgControlStyles = { ··· 196 195 <ImageEditor 197 196 ref={editorRef} 198 197 style={styles.imgEditor} 199 - image={image.compressed.path} 198 + image={image.cropped.path} 200 199 scale={scale} 201 200 border={0} 202 201 position={position}
+4
src/view/com/modals/Modal.tsx
··· 15 15 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' 16 16 import * as ListAddRemoveUserModal from './ListAddRemoveUser' 17 17 import * as AltImageModal from './AltImage' 18 + import * as EditImageModal from './AltImage' 18 19 import * as ReportAccountModal from './ReportAccount' 19 20 import * as DeleteAccountModal from './DeleteAccount' 20 21 import * as ChangeHandleModal from './ChangeHandle' ··· 83 84 } else if (activeModal?.name === 'alt-text-image') { 84 85 snapPoints = AltImageModal.snapPoints 85 86 element = <AltImageModal.Component {...activeModal} /> 87 + } else if (activeModal?.name === 'edit-image') { 88 + snapPoints = AltImageModal.snapPoints 89 + element = <EditImageModal.Component {...activeModal} /> 86 90 } else if (activeModal?.name === 'change-handle') { 87 91 snapPoints = ChangeHandleModal.snapPoints 88 92 element = <ChangeHandleModal.Component {...activeModal} />
-11
src/view/com/modals/crop-image/CropImage.tsx
··· 1 - /** 2 - * NOTE 3 - * This modal is used only in the web build 4 - * Native uses a third-party library 5 - */ 6 - 7 - export const snapPoints = ['0%'] 8 - 9 - export function Component() { 10 - return null 11 - }