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