about summary refs log tree commit diff
path: root/src/lib/media
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/media')
-rw-r--r--src/lib/media/manip.ts178
-rw-r--r--src/lib/media/manip.web.ts88
-rw-r--r--src/lib/media/picker.tsx141
-rw-r--r--src/lib/media/picker.web.tsx144
-rw-r--r--src/lib/media/types.ts31
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
+}