diff options
Diffstat (limited to 'src/state/gallery.ts')
-rw-r--r-- | src/state/gallery.ts | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/src/state/gallery.ts b/src/state/gallery.ts new file mode 100644 index 000000000..f4c8b712e --- /dev/null +++ b/src/state/gallery.ts @@ -0,0 +1,299 @@ +import { + cacheDirectory, + deleteAsync, + makeDirectoryAsync, + moveAsync, +} from 'expo-file-system' +import { + Action, + ActionCrop, + manipulateAsync, + SaveFormat, +} from 'expo-image-manipulator' +import {nanoid} from 'nanoid/non-secure' + +import {POST_IMG_MAX} from '#/lib/constants' +import {getImageDim} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {isIOS, isNative} from '#/platform/detection' + +export type ImageTransformation = { + crop?: ActionCrop['crop'] +} + +export type ImageMeta = { + path: string + width: number + height: number + mime: string +} + +export type ImageSource = ImageMeta & { + id: string +} + +type ComposerImageBase = { + alt: string + source: ImageSource +} +type ComposerImageWithoutTransformation = ComposerImageBase & { + transformed?: undefined + manips?: undefined +} +type ComposerImageWithTransformation = ComposerImageBase & { + transformed: ImageMeta + manips?: ImageTransformation +} + +export type ComposerImage = + | ComposerImageWithoutTransformation + | ComposerImageWithTransformation + +let _imageCacheDirectory: string + +function getImageCacheDirectory(): string | null { + if (isNative) { + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) + } + + return null +} + +export async function createComposerImage( + raw: ImageMeta, +): Promise<ComposerImageWithoutTransformation> { + return { + alt: '', + source: { + id: nanoid(), + path: await moveIfNecessary(raw.path), + width: raw.width, + height: raw.height, + mime: raw.mime, + }, + } +} + +export type InitialImage = { + uri: string + width: number + height: number + altText?: string +} + +export function createInitialImages( + uris: InitialImage[] = [], +): ComposerImageWithoutTransformation[] { + return uris.map(({uri, width, height, altText = ''}) => { + return { + alt: altText, + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } + }) +} + +export async function pasteImage( + uri: string, +): Promise<ComposerImageWithoutTransformation> { + const {width, height} = await getImageDim(uri) + const match = /^data:(.+?);/.exec(uri) + + return { + alt: '', + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: match ? match[1] : 'image/jpeg', + }, + } +} + +export async function cropImage(img: ComposerImage): Promise<ComposerImage> { + if (!isNative) { + return img + } + + // NOTE + // on ios, react-native-image-crop-picker gives really bad quality + // without specifying width and height. on android, however, the + // crop stretches incorrectly if you do specify it. these are + // both separate bugs in the library. we deal with that by + // providing width & height for ios only + // -prf + + const source = img.source + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + // @todo: we're always passing the original image here, does image-cropper + // allows for setting initial crop dimensions? -mary + try { + const cropped = await openCropper({ + mediaType: 'photo', + path: source.path, + freeStyleCropEnabled: true, + ...(isIOS ? {width: w, height: h} : {}), + }) + + return { + alt: img.alt, + source: source, + transformed: { + path: await moveIfNecessary(cropped.path), + width: cropped.width, + height: cropped.height, + mime: cropped.mime, + }, + } + } catch (e) { + if (e instanceof Error && e.message.includes('User cancelled')) { + return img + } + + throw e + } +} + +export async function manipulateImage( + img: ComposerImage, + trans: ImageTransformation, +): Promise<ComposerImage> { + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] + + const actions = rawActions.filter((a): a is Action => a !== undefined) + + if (actions.length === 0) { + if (img.transformed === undefined) { + return img + } + + return {alt: img.alt, source: img.source} + } + + const source = img.source + const result = await manipulateAsync(source.path, actions, { + format: SaveFormat.PNG, + }) + + return { + alt: img.alt, + source: img.source, + transformed: { + path: await moveIfNecessary(result.uri), + width: result.width, + height: result.height, + mime: 'image/png', + }, + manips: trans, + } +} + +export function resetImageManipulation( + img: ComposerImage, +): ComposerImageWithoutTransformation { + if (img.transformed !== undefined) { + return {alt: img.alt, source: img.source} + } + + return img +} + +export async function compressImage(img: ComposerImage): Promise<ImageMeta> { + const source = img.transformed || img.source + + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + const cacheDir = isNative && getImageCacheDirectory() + + for (let i = 10; i > 0; i--) { + // Float precision + const factor = i / 10 + + const res = await manipulateAsync( + source.path, + [{resize: {width: w, height: h}}], + { + compress: factor, + format: SaveFormat.JPEG, + base64: true, + }, + ) + + const base64 = res.base64 + + if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { + return { + path: await moveIfNecessary(res.uri), + width: res.width, + height: res.height, + mime: 'image/jpeg', + } + } + + if (cacheDir) { + await deleteAsync(res.uri) + } + } + + throw new Error(`Unable to compress image`) +} + +async function moveIfNecessary(from: string) { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir && from.startsWith(cacheDir)) { + const to = joinPath(cacheDir, nanoid(36)) + + await makeDirectoryAsync(cacheDir, {intermediates: true}) + await moveAsync({from, to}) + + return to + } + + return from +} + +/** Purge files that were created to accomodate image manipulation */ +export async function purgeTemporaryImageFiles() { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir) { + await deleteAsync(cacheDir, {idempotent: true}) + await makeDirectoryAsync(cacheDir) + } +} + +function joinPath(a: string, b: string) { + if (a.endsWith('/')) { + if (b.startsWith('/')) { + return a.slice(0, -1) + b + } + return a + b + } else if (b.startsWith('/')) { + return a + b + } + return a + '/' + b +} + +function containImageRes( + w: number, + h: number, + {width: maxW, height: maxH}: {width: number; height: number}, +): [width: number, height: number] { + let scale = 1 + + if (w > maxW || h > maxH) { + scale = w > h ? maxW / w : maxH / h + w = Math.floor(w * scale) + h = Math.floor(h * scale) + } + + return [w, h] +} |