diff options
Diffstat (limited to 'src/lib/media')
-rw-r--r-- | src/lib/media/manip.ts | 178 | ||||
-rw-r--r-- | src/lib/media/manip.web.ts | 88 | ||||
-rw-r--r-- | src/lib/media/picker.tsx | 141 | ||||
-rw-r--r-- | src/lib/media/picker.web.tsx | 144 | ||||
-rw-r--r-- | src/lib/media/types.ts | 31 |
5 files changed, 582 insertions, 0 deletions
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts new file mode 100644 index 000000000..e44ee3907 --- /dev/null +++ b/src/lib/media/manip.ts @@ -0,0 +1,178 @@ +import RNFetchBlob from 'rn-fetch-blob' +import ImageResizer from '@bam.tech/react-native-image-resizer' +import {Image as RNImage, Share} from 'react-native' +import RNFS from 'react-native-fs' +import uuid from 'react-native-uuid' +import * as Toast from 'view/com/util/Toast' + +export interface DownloadAndResizeOpts { + uri: string + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number + timeout: number +} + +export interface Image { + path: string + mime: string + size: number + width: number + height: number +} + +export async function downloadAndResize(opts: DownloadAndResizeOpts) { + let appendExt = 'jpeg' + try { + const urip = new URL(opts.uri) + const ext = urip.pathname.split('.').pop() + if (ext === 'png') { + appendExt = 'png' + } + } catch (e: any) { + console.error('Invalid URI', opts.uri, e) + return + } + + let downloadRes + try { + const downloadResPromise = RNFetchBlob.config({ + fileCache: true, + appendExt, + }).fetch('GET', opts.uri) + const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout) + downloadRes = await downloadResPromise + clearTimeout(to1) + + let localUri = downloadRes.path() + if (!localUri.startsWith('file://')) { + localUri = `file://${localUri}` + } + + return await resize(localUri, opts) + } finally { + if (downloadRes) { + downloadRes.flush() + } + } +} + +export interface ResizeOpts { + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number +} + +export async function resize( + localUri: string, + opts: ResizeOpts, +): Promise<Image> { + for (let i = 0; i < 9; i++) { + const quality = 100 - i * 10 + const resizeRes = await ImageResizer.createResizedImage( + localUri, + opts.width, + opts.height, + 'JPEG', + quality, + undefined, + undefined, + undefined, + {mode: opts.mode}, + ) + if (resizeRes.size < opts.maxSize) { + return { + path: resizeRes.path, + mime: 'image/jpeg', + size: resizeRes.size, + width: resizeRes.width, + height: resizeRes.height, + } + } + } + throw new Error( + `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`, + ) +} + +export async function compressIfNeeded( + img: Image, + maxSize: number, +): Promise<Image> { + const origUri = `file://${img.path}` + if (img.size < maxSize) { + return img + } + const resizedImage = await resize(origUri, { + width: img.width, + height: img.height, + mode: 'stretch', + maxSize, + }) + const finalImageMovedPath = await moveToPremanantPath(resizedImage.path) + const finalImg = { + ...resizedImage, + path: finalImageMovedPath, + } + return finalImg +} + +export interface Dim { + width: number + height: number +} +export function scaleDownDimensions(dim: Dim, max: Dim): Dim { + if (dim.width < max.width && dim.height < max.height) { + return dim + } + let wScale = dim.width > max.width ? max.width / dim.width : 1 + let hScale = dim.height > max.height ? max.height / dim.height : 1 + if (wScale < hScale) { + return {width: dim.width * wScale, height: dim.height * wScale} + } + return {width: dim.width * hScale, height: dim.height * hScale} +} + +export async function saveImageModal({uri}: {uri: string}) { + const downloadResponse = await RNFetchBlob.config({ + fileCache: true, + }).fetch('GET', uri) + + const imagePath = downloadResponse.path() + const base64Data = await downloadResponse.readFile('base64') + const result = await Share.share({ + url: 'data:image/png;base64,' + base64Data, + }) + if (result.action === Share.sharedAction) { + Toast.show('Image saved to gallery') + } else if (result.action === Share.dismissedAction) { + // dismissed + } + RNFS.unlink(imagePath) +} + +export async function moveToPremanantPath(path: string) { + /* + Since this package stores images in a temp directory, we need to move the file to a permanent location. + Relevant: IOS bug when trying to open a second time: + https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 + */ + const filename = uuid.v4() + const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` + RNFS.moveFile(path, destinationPath) + return destinationPath +} + +export function getImageDim(path: string): Promise<Dim> { + return new Promise((resolve, reject) => { + RNImage.getSize( + path, + (width, height) => { + resolve({width, height}) + }, + reject, + ) + }) +} diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts new file mode 100644 index 000000000..e617d01af --- /dev/null +++ b/src/lib/media/manip.web.ts @@ -0,0 +1,88 @@ +// import {Share} from 'react-native' +// import * as Toast from 'view/com/util/Toast' + +export interface DownloadAndResizeOpts { + uri: string + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number + timeout: number +} + +export interface Image { + path: string + mime: string + size: number + width: number + height: number +} + +export async function downloadAndResize(_opts: DownloadAndResizeOpts) { + // TODO + throw new Error('TODO') +} + +export interface ResizeOpts { + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number +} + +export async function resize( + _localUri: string, + _opts: ResizeOpts, +): Promise<Image> { + // TODO + throw new Error('TODO') +} + +export async function compressIfNeeded( + img: Image, + maxSize: number, +): Promise<Image> { + if (img.size > maxSize) { + // TODO + throw new Error( + "This image is too large and we haven't implemented compression yet -- sorry!", + ) + } + return img +} + +export interface Dim { + width: number + height: number +} +export function scaleDownDimensions(dim: Dim, max: Dim): Dim { + if (dim.width < max.width && dim.height < max.height) { + return dim + } + let wScale = dim.width > max.width ? max.width / dim.width : 1 + let hScale = dim.height > max.height ? max.height / dim.height : 1 + if (wScale < hScale) { + return {width: dim.width * wScale, height: dim.height * wScale} + } + return {width: dim.width * hScale, height: dim.height * hScale} +} + +export async function saveImageModal(_opts: {uri: string}) { + // TODO + throw new Error('TODO') +} + +export async function moveToPremanantPath(path: string) { + return path +} + +export async function getImageDim(path: string): Promise<Dim> { + var img = document.createElement('img') + const promise = new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = reject + }) + img.src = path + await promise + return {width: img.width, height: img.height} +} diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx new file mode 100644 index 000000000..940366035 --- /dev/null +++ b/src/lib/media/picker.tsx @@ -0,0 +1,141 @@ +import { + openPicker as openPickerFn, + openCamera as openCameraFn, + openCropper as openCropperFn, + ImageOrVideo, +} from 'react-native-image-crop-picker' +import {RootStoreModel} from 'state/index' +import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' +import { + scaleDownDimensions, + Dim, + compressIfNeeded, + moveToPremanantPath, +} from 'lib/media/manip' +export type {PickedMedia} from './types' + +/** + * NOTE + * These methods all include the RootStoreModel as the first param + * because the web versions require it. The signatures have to remain + * equivalent between the different forms, but the store param is not + * used here. + * -prf + */ + +export async function openPicker( + _store: RootStoreModel, + opts: PickerOpts, +): Promise<PickedMedia[]> { + const mediaType = opts.mediaType || 'photo' + const items = await openPickerFn({ + mediaType, + multiple: opts.multiple, + maxFiles: opts.maxFiles, + }) + const toMedia = (item: ImageOrVideo) => ({ + mediaType, + path: item.path, + mime: item.mime, + size: item.size, + width: item.width, + height: item.height, + }) + if (Array.isArray(items)) { + return items.map(toMedia) + } + return [toMedia(items)] +} + +export async function openCamera( + _store: RootStoreModel, + opts: CameraOpts, +): Promise<PickedMedia> { + const mediaType = opts.mediaType || 'photo' + const item = await openCameraFn({ + mediaType, + width: opts.width, + height: opts.height, + freeStyleCropEnabled: opts.freeStyleCropEnabled, + cropperCircleOverlay: opts.cropperCircleOverlay, + cropping: true, + forceJpg: true, // ios only + compressImageQuality: 1.0, + }) + return { + mediaType, + path: item.path, + mime: item.mime, + size: item.size, + width: item.width, + height: item.height, + } +} + +export async function openCropper( + _store: RootStoreModel, + opts: CropperOpts, +): Promise<PickedMedia> { + const mediaType = opts.mediaType || 'photo' + const item = await openCropperFn({ + path: opts.path, + mediaType: opts.mediaType || 'photo', + width: opts.width, + height: opts.height, + freeStyleCropEnabled: opts.freeStyleCropEnabled, + cropperCircleOverlay: opts.cropperCircleOverlay, + forceJpg: true, // ios only + compressImageQuality: 1.0, + }) + return { + mediaType, + path: item.path, + mime: item.mime, + size: item.size, + width: item.width, + height: item.height, + } +} + +export async function pickImagesFlow( + store: RootStoreModel, + maxFiles: number, + maxDim: Dim, + maxSize: number, +) { + const items = await openPicker(store, { + multiple: true, + maxFiles, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), + ) + } + return result +} + +export async function cropAndCompressFlow( + store: RootStoreModel, + path: string, + imgDim: Dim, + maxDim: Dim, + maxSize: number, +) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions(imgDim, maxDim) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path, + freeStyleCropEnabled: true, + width, + height, + }) + + const img = await compressIfNeeded(cropperRes, maxSize) + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath +} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx new file mode 100644 index 000000000..746feaedd --- /dev/null +++ b/src/lib/media/picker.web.tsx @@ -0,0 +1,144 @@ +/// <reference lib="dom" /> + +import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' +export type {PickedMedia} from './types' +import {RootStoreModel} from 'state/index' +import { + scaleDownDimensions, + getImageDim, + Dim, + compressIfNeeded, + moveToPremanantPath, +} from 'lib/media/manip' + +interface PickedFile { + uri: string + path: string + size: number +} + +export async function openPicker( + _store: RootStoreModel, + opts: PickerOpts, +): Promise<PickedMedia[]> { + const res = await selectFile(opts) + const dim = await getImageDim(res.uri) + const mime = extractDataUriMime(res.uri) + return [ + { + mediaType: 'photo', + path: res.uri, + mime, + size: res.size, + width: dim.width, + height: dim.height, + }, + ] +} + +export async function openCamera( + _store: RootStoreModel, + _opts: CameraOpts, +): Promise<PickedMedia> { + // const mediaType = opts.mediaType || 'photo' TODO + throw new Error('TODO') +} + +export async function openCropper( + store: RootStoreModel, + opts: CropperOpts, +): Promise<PickedMedia> { + // TODO handle more opts + return new Promise((resolve, reject) => { + store.shell.openModal({ + name: 'crop-image', + uri: opts.path, + onSelect: (img?: PickedMedia) => { + if (img) { + resolve(img) + } else { + reject(new Error('Canceled')) + } + }, + }) + }) +} + +export async function pickImagesFlow( + store: RootStoreModel, + maxFiles: number, + maxDim: Dim, + maxSize: number, +) { + const items = await openPicker(store, { + multiple: true, + maxFiles, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), + ) + } + return result +} + +export async function cropAndCompressFlow( + store: RootStoreModel, + path: string, + imgDim: Dim, + maxDim: Dim, + maxSize: number, +) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions(imgDim, maxDim) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path, + freeStyleCropEnabled: true, + width, + height, + }) + + const img = await compressIfNeeded(cropperRes, maxSize) + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath +} + +// helpers +// = + +function selectFile(opts: PickerOpts): Promise<PickedFile> { + return new Promise((resolve, reject) => { + var input = document.createElement('input') + input.type = 'file' + input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*' + input.onchange = e => { + const target = e.target as HTMLInputElement + const file = target?.files?.[0] + if (!file) { + return reject(new Error('Canceled')) + } + + var reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = readerEvent => { + if (!readerEvent.target) { + return reject(new Error('Canceled')) + } + resolve({ + uri: readerEvent.target.result as string, + path: file.name, + size: file.size, + }) + } + } + input.click() + }) +} + +function extractDataUriMime(uri: string): string { + return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) +} diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts new file mode 100644 index 000000000..3197b4d3e --- /dev/null +++ b/src/lib/media/types.ts @@ -0,0 +1,31 @@ +export interface PickerOpts { + mediaType?: 'photo' + multiple?: boolean + maxFiles?: number +} + +export interface CameraOpts { + mediaType?: 'photo' + width: number + height: number + freeStyleCropEnabled?: boolean + cropperCircleOverlay?: boolean +} + +export interface CropperOpts { + path: string + mediaType?: 'photo' + width: number + height: number + freeStyleCropEnabled?: boolean + cropperCircleOverlay?: boolean +} + +export interface PickedMedia { + mediaType: 'photo' + path: string + mime: string + size: number + width: number + height: number +} |