Rework scaled dimensions and compression (#737)

* Rework scaled dimensions and compression

* Unbreak image / banner uploads

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Ollie H 2023-05-30 17:23:55 -07:00 committed by GitHub
parent deebe18aaa
commit 072682dd9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 175 additions and 238 deletions

View file

@ -16,6 +16,7 @@ export class ImageSizesCache {
if (Dimensions) {
return Dimensions
}
const prom =
this.activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {

View file

@ -4,7 +4,6 @@ import {ImageModel} from './image'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip'
import {getDataUriSize} from 'lib/media/util'
import {isNative} from 'platform/detection'
export class GalleryModel {
@ -24,13 +23,7 @@ export class GalleryModel {
return this.images.length
}
get paths() {
return this.images.map(image =>
image.compressed === undefined ? image.path : image.compressed.path,
)
}
async add(image_: RNImage) {
async add(image_: Omit<RNImage, 'size'>) {
if (this.size >= 4) {
return
}
@ -39,15 +32,9 @@ export class GalleryModel {
if (!this.images.some(i => i.path === image_.path)) {
const image = new ImageModel(this.rootStore, image_)
if (!isNative) {
await image.manipulate({})
} else {
await image.compress()
}
runInAction(() => {
this.images.push(image)
})
// Initial resize
image.manipulate({})
this.images.push(image)
}
}
@ -70,11 +57,10 @@ export class GalleryModel {
const {width, height} = await getImageDim(uri)
const image: RNImage = {
const image = {
path: uri,
height,
width,
size: getDataUriSize(uri),
mime: 'image/jpeg',
}

View file

@ -3,14 +3,11 @@ import {RootStoreModel} from 'state/index'
import {makeAutoObservable, runInAction} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import * as ImageManipulator from 'expo-image-manipulator'
import {getDataUriSize, scaleDownDimensions} from 'lib/media/util'
import {getDataUriSize} from 'lib/media/util'
import {openCropper} from 'lib/media/picker'
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
import {Position} from 'react-avatar-editor'
import {compressAndResizeImageForPost} from 'lib/media/manip'
// TODO: EXIF embed
// Cases to consider: ExternalEmbed
import {Dimensions} from 'lib/media/types'
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
@ -21,17 +18,16 @@ export interface ImageManipulationAttributes {
flipVertical?: boolean
}
export class ImageModel implements RNImage {
const MAX_IMAGE_SIZE_IN_BYTES = 976560
export class ImageModel implements Omit<RNImage, 'size'> {
path: string
mime = 'image/jpeg'
width: number
height: number
size: number
altText = ''
cropped?: RNImage = undefined
compressed?: RNImage = undefined
scaledWidth: number = POST_IMG_MAX.width
scaledHeight: number = POST_IMG_MAX.height
// Web manipulation
prev?: RNImage
@ -44,7 +40,7 @@ export class ImageModel implements RNImage {
}
prevAttributes: ImageManipulationAttributes = {}
constructor(public rootStore: RootStoreModel, image: RNImage) {
constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
makeAutoObservable(this, {
rootStore: false,
})
@ -52,19 +48,8 @@ export class ImageModel implements RNImage {
this.path = image.path
this.width = image.width
this.height = image.height
this.size = image.size
this.calcScaledDimensions()
}
// TODO: Revisit compression factor due to updated sizing with zoom
// get compressionFactor() {
// const MAX_IMAGE_SIZE_IN_BYTES = 976560
// return this.size < MAX_IMAGE_SIZE_IN_BYTES
// ? 1
// : MAX_IMAGE_SIZE_IN_BYTES / this.size
// }
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
@ -93,8 +78,24 @@ export class ImageModel implements RNImage {
}
}
getDisplayDimensions(
as: ImageManipulationAttributes['aspectRatio'] = '1:1',
getUploadDimensions(
dimensions: Dimensions,
maxDimensions: Dimensions = POST_IMG_MAX,
as: ImageManipulationAttributes['aspectRatio'] = 'None',
) {
const {width, height} = dimensions
const {width: maxWidth, height: maxHeight} = maxDimensions
return width < maxWidth && height < maxHeight
? {
width,
height,
}
: this.getResizedDimensions(as, POST_IMG_MAX.width)
}
getResizedDimensions(
as: ImageManipulationAttributes['aspectRatio'] = 'None',
maxSide: number,
) {
const ratioMultiplier = this.ratioMultipliers[as]
@ -119,59 +120,70 @@ export class ImageModel implements RNImage {
}
}
calcScaledDimensions() {
const {width, height} = scaleDownDimensions(
{width: this.width, height: this.height},
POST_IMG_MAX,
)
this.scaledWidth = width
this.scaledHeight = height
}
async setAltText(altText: string) {
this.altText = altText
}
// Only for mobile
// Only compress prior to upload
async compress() {
for (let i = 10; i > 0; i--) {
// Float precision
const factor = Math.round(i) / 10
const compressed = await ImageManipulator.manipulateAsync(
this.cropped?.path ?? this.path,
undefined,
{
compress: factor,
base64: true,
format: SaveFormat.JPEG,
},
)
if (compressed.base64 !== undefined) {
const size = getDataUriSize(compressed.base64)
if (size < MAX_IMAGE_SIZE_IN_BYTES) {
runInAction(() => {
this.compressed = {
mime: 'image/jpeg',
path: compressed.uri,
size,
...compressed,
}
})
return
}
}
}
// Compression fails when removing redundant information is not possible.
// This can be tested with images that have high variance in noise.
throw new Error('Failed to compress image')
}
// Mobile
async crop() {
try {
// openCropper requires an output width and height hence
// getting upload dimensions before cropping is necessary.
const {width, height} = this.getUploadDimensions({
width: this.width,
height: this.height,
})
const cropped = await openCropper(this.rootStore, {
mediaType: 'photo',
path: this.path,
freeStyleCropEnabled: true,
width: this.scaledWidth,
height: this.scaledHeight,
})
runInAction(() => {
this.cropped = cropped
this.compress()
})
} catch (err) {
this.rootStore.log.error('Failed to crop photo', err)
}
}
async compress() {
try {
const {width, height} = scaleDownDimensions(
this.cropped
? {width: this.cropped.width, height: this.cropped.height}
: {width: this.width, height: this.height},
POST_IMG_MAX,
)
// TODO: Revisit this - currently iOS uses this as well
const compressed = await compressAndResizeImageForPost({
...(this.cropped === undefined ? this : this.cropped),
width,
height,
})
runInAction(() => {
this.compressed = compressed
this.cropped = cropped
})
} catch (err) {
this.rootStore.log.error('Failed to compress photo', err)
this.rootStore.log.error('Failed to crop photo', err)
}
}
@ -181,6 +193,9 @@ export class ImageModel implements RNImage {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
let uploadWidth: number | undefined
let uploadHeight: number | undefined
const {aspectRatio, crop, position, scale} = attributes
const modifiers = []
@ -197,14 +212,34 @@ export class ImageModel implements RNImage {
}
if (crop !== undefined) {
const croppedHeight = crop.height * this.height
const croppedWidth = crop.width * this.width
modifiers.push({
crop: {
originX: crop.originX * this.width,
originY: crop.originY * this.height,
height: crop.height * this.height,
width: crop.width * this.width,
height: croppedHeight,
width: croppedWidth,
},
})
const uploadDimensions = this.getUploadDimensions(
{width: croppedWidth, height: croppedHeight},
POST_IMG_MAX,
aspectRatio,
)
uploadWidth = uploadDimensions.width
uploadHeight = uploadDimensions.height
} else {
const uploadDimensions = this.getUploadDimensions(
{width: this.width, height: this.height},
POST_IMG_MAX,
aspectRatio,
)
uploadWidth = uploadDimensions.width
uploadHeight = uploadDimensions.height
}
if (scale !== undefined) {
@ -222,36 +257,40 @@ export class ImageModel implements RNImage {
const ratioMultiplier =
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
const MAX_SIDE = 2000
const result = await ImageManipulator.manipulateAsync(
this.path,
[
...modifiers,
{resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
{
resize:
ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
},
],
{
compress: 0.9,
base64: true,
format: SaveFormat.JPEG,
},
)
runInAction(() => {
this.compressed = {
this.cropped = {
mime: 'image/jpeg',
path: result.uri,
size: getDataUriSize(result.uri),
size:
result.base64 !== undefined
? getDataUriSize(result.base64)
: MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
...result,
}
})
}
resetCompressed() {
resetCropped() {
this.manipulate({})
}
previous() {
this.compressed = this.prev
this.cropped = this.prev
this.attributes = this.prevAttributes
}
}