Bluesky app fork with some witchin' additions 馃挮
at cb54cd87bb36c92ebcd59cd0b2edb77245b1b999 300 lines 6.7 kB view raw
1import { 2 cacheDirectory, 3 deleteAsync, 4 makeDirectoryAsync, 5 moveAsync, 6} from 'expo-file-system/legacy' 7import { 8 type Action, 9 type ActionCrop, 10 manipulateAsync, 11 SaveFormat, 12} from 'expo-image-manipulator' 13import {type BlobRef} from '@atproto/api' 14import {nanoid} from 'nanoid/non-secure' 15 16import {POST_IMG_MAX} from '#/lib/constants' 17import {getImageDim} from '#/lib/media/manip' 18import {openCropper} from '#/lib/media/picker' 19import {type PickerImage} from '#/lib/media/picker.shared' 20import {getDataUriSize} from '#/lib/media/util' 21import {isCancelledError} from '#/lib/strings/errors' 22import {IS_NATIVE} from '#/env' 23 24export type ImageTransformation = { 25 crop?: ActionCrop['crop'] 26} 27 28export type ImageMeta = { 29 path: string 30 width: number 31 height: number 32 mime: string 33} 34 35export type ImageSource = ImageMeta & { 36 id: string 37} 38 39type ComposerImageBase = { 40 alt: string 41 source: ImageSource 42 blobRef?: BlobRef 43} 44type ComposerImageWithoutTransformation = ComposerImageBase & { 45 transformed?: undefined 46 manips?: undefined 47} 48type ComposerImageWithTransformation = ComposerImageBase & { 49 transformed: ImageMeta 50 manips?: ImageTransformation 51} 52 53export type ComposerImage = 54 | ComposerImageWithoutTransformation 55 | ComposerImageWithTransformation 56 57let _imageCacheDirectory: string 58 59function getImageCacheDirectory(): string | null { 60 if (IS_NATIVE) { 61 return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 62 } 63 64 return null 65} 66 67export async function createComposerImage( 68 raw: ImageMeta, 69): Promise<ComposerImageWithoutTransformation> { 70 return { 71 alt: '', 72 source: { 73 id: nanoid(), 74 path: await moveIfNecessary(raw.path), 75 width: raw.width, 76 height: raw.height, 77 mime: raw.mime, 78 }, 79 } 80} 81 82export type InitialImage = { 83 uri: string 84 width: number 85 height: number 86 altText?: string 87 blobRef?: BlobRef 88} 89 90export function createInitialImages( 91 uris: InitialImage[] = [], 92): ComposerImageWithoutTransformation[] { 93 return uris.map(({uri, width, height, altText = '', blobRef}) => { 94 return { 95 alt: altText, 96 source: { 97 id: nanoid(), 98 path: uri, 99 width: width, 100 height: height, 101 mime: 'image/jpeg', 102 }, 103 blobRef, 104 } 105 }) 106} 107 108export async function pasteImage( 109 uri: string, 110): Promise<ComposerImageWithoutTransformation> { 111 const {width, height} = await getImageDim(uri) 112 const match = /^data:(.+?);/.exec(uri) 113 114 return { 115 alt: '', 116 source: { 117 id: nanoid(), 118 path: uri, 119 width: width, 120 height: height, 121 mime: match ? match[1] : 'image/jpeg', 122 }, 123 } 124} 125 126export async function cropImage(img: ComposerImage): Promise<ComposerImage> { 127 if (!IS_NATIVE) { 128 return img 129 } 130 131 const source = img.source 132 133 // @todo: we're always passing the original image here, does image-cropper 134 // allows for setting initial crop dimensions? -mary 135 try { 136 const cropped = await openCropper({ 137 imageUri: source.path, 138 }) 139 140 return { 141 alt: img.alt, 142 source: source, 143 transformed: { 144 path: await moveIfNecessary(cropped.path), 145 width: cropped.width, 146 height: cropped.height, 147 mime: cropped.mime, 148 }, 149 } 150 } catch (e) { 151 if (!isCancelledError(e)) { 152 return img 153 } 154 155 throw e 156 } 157} 158 159export async function manipulateImage( 160 img: ComposerImage, 161 trans: ImageTransformation, 162): Promise<ComposerImage> { 163 const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] 164 165 const actions = rawActions.filter((a): a is Action => a !== undefined) 166 167 if (actions.length === 0) { 168 if (img.transformed === undefined) { 169 return img 170 } 171 172 return {alt: img.alt, source: img.source} 173 } 174 175 const source = img.source 176 const result = await manipulateAsync(source.path, actions, { 177 format: SaveFormat.PNG, 178 }) 179 180 return { 181 alt: img.alt, 182 source: img.source, 183 transformed: { 184 path: await moveIfNecessary(result.uri), 185 width: result.width, 186 height: result.height, 187 mime: 'image/png', 188 }, 189 manips: trans, 190 } 191} 192 193export function resetImageManipulation( 194 img: ComposerImage, 195): ComposerImageWithoutTransformation { 196 if (img.transformed !== undefined) { 197 return {alt: img.alt, source: img.source} 198 } 199 200 return img 201} 202 203export async function compressImage(img: ComposerImage): Promise<PickerImage> { 204 const source = img.transformed || img.source 205 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 206 207 let minQualityPercentage = 0 208 let maxQualityPercentage = 101 // exclusive 209 let newDataUri 210 211 while (maxQualityPercentage - minQualityPercentage > 1) { 212 const qualityPercentage = Math.round( 213 (maxQualityPercentage + minQualityPercentage) / 2, 214 ) 215 216 const res = await manipulateAsync( 217 source.path, 218 [{resize: {width: w, height: h}}], 219 { 220 compress: qualityPercentage / 100, 221 format: SaveFormat.JPEG, 222 base64: true, 223 }, 224 ) 225 226 const base64 = res.base64 227 const size = base64 ? getDataUriSize(base64) : 0 228 if (base64 && size <= POST_IMG_MAX.size) { 229 minQualityPercentage = qualityPercentage 230 newDataUri = { 231 path: await moveIfNecessary(res.uri), 232 width: res.width, 233 height: res.height, 234 mime: 'image/jpeg', 235 size, 236 } 237 } else { 238 maxQualityPercentage = qualityPercentage 239 } 240 } 241 242 if (newDataUri) { 243 return newDataUri 244 } 245 246 throw new Error(`Unable to compress image`) 247} 248 249async function moveIfNecessary(from: string) { 250 const cacheDir = IS_NATIVE && getImageCacheDirectory() 251 252 if (cacheDir && from.startsWith(cacheDir)) { 253 const to = joinPath(cacheDir, nanoid(36)) 254 255 await makeDirectoryAsync(cacheDir, {intermediates: true}) 256 await moveAsync({from, to}) 257 258 return to 259 } 260 261 return from 262} 263 264/** Purge files that were created to accomodate image manipulation */ 265export async function purgeTemporaryImageFiles() { 266 const cacheDir = IS_NATIVE && getImageCacheDirectory() 267 268 if (cacheDir) { 269 await deleteAsync(cacheDir, {idempotent: true}) 270 await makeDirectoryAsync(cacheDir) 271 } 272} 273 274function joinPath(a: string, b: string) { 275 if (a.endsWith('/')) { 276 if (b.startsWith('/')) { 277 return a.slice(0, -1) + b 278 } 279 return a + b 280 } else if (b.startsWith('/')) { 281 return a + b 282 } 283 return a + '/' + b 284} 285 286function containImageRes( 287 w: number, 288 h: number, 289 {width: maxW, height: maxH}: {width: number; height: number}, 290): [width: number, height: number] { 291 let scale = 1 292 293 if (w > maxW || h > maxH) { 294 scale = w > h ? maxW / w : maxH / h 295 w = Math.floor(w * scale) 296 h = Math.floor(h * scale) 297 } 298 299 return [w, h] 300}