Bluesky app fork with some witchin' additions 馃挮
at 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 366 lines 8.7 kB view raw
1import { 2 cacheDirectory, 3 copyAsync, 4 deleteAsync, 5 makeDirectoryAsync, 6 moveAsync, 7} from 'expo-file-system/legacy' 8import { 9 type Action, 10 type ActionCrop, 11 manipulateAsync, 12 SaveFormat, 13} from 'expo-image-manipulator' 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, IS_WEB} 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 /** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */ 43 localRefPath?: string 44} 45type ComposerImageWithoutTransformation = ComposerImageBase & { 46 transformed?: undefined 47 manips?: undefined 48} 49type ComposerImageWithTransformation = ComposerImageBase & { 50 transformed: ImageMeta 51 manips?: ImageTransformation 52} 53 54export type ComposerImage = 55 | ComposerImageWithoutTransformation 56 | ComposerImageWithTransformation 57 58let _imageCacheDirectory: string 59 60function getImageCacheDirectory(): string | null { 61 if (IS_NATIVE) { 62 return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 63 } 64 65 return null 66} 67 68export async function createComposerImage( 69 raw: ImageMeta, 70): Promise<ComposerImageWithoutTransformation> { 71 return { 72 alt: '', 73 source: { 74 id: nanoid(), 75 // Copy to cache to ensure file survives OS temporary file cleanup 76 path: await copyToCache(raw.path), 77 width: raw.width, 78 height: raw.height, 79 mime: raw.mime, 80 }, 81 } 82} 83 84export type InitialImage = { 85 uri: string 86 width: number 87 height: number 88 altText?: string 89} 90 91export function createInitialImages( 92 uris: InitialImage[] = [], 93): ComposerImageWithoutTransformation[] { 94 return uris.map(({uri, width, height, altText = ''}) => { 95 return { 96 alt: altText, 97 source: { 98 id: nanoid(), 99 path: uri, 100 width: width, 101 height: height, 102 mime: 'image/jpeg', 103 }, 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 206 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 207 208 let minQualityPercentage = 0 209 let maxQualityPercentage = 101 // exclusive 210 let newDataUri 211 212 while (maxQualityPercentage - minQualityPercentage > 1) { 213 const qualityPercentage = Math.round( 214 (maxQualityPercentage + minQualityPercentage) / 2, 215 ) 216 217 const res = await manipulateAsync( 218 source.path, 219 [{resize: {width: w, height: h}}], 220 { 221 compress: qualityPercentage / 100, 222 format: SaveFormat.JPEG, 223 base64: true, 224 }, 225 ) 226 227 const base64 = res.base64 228 const size = base64 ? getDataUriSize(base64) : 0 229 if (base64 && size <= POST_IMG_MAX.size) { 230 minQualityPercentage = qualityPercentage 231 newDataUri = { 232 path: await moveIfNecessary(res.uri), 233 width: res.width, 234 height: res.height, 235 mime: 'image/jpeg', 236 size, 237 } 238 } else { 239 maxQualityPercentage = qualityPercentage 240 } 241 } 242 243 if (newDataUri) { 244 return newDataUri 245 } 246 247 throw new Error(`Unable to compress image`) 248} 249 250async function moveIfNecessary(from: string) { 251 const cacheDir = IS_NATIVE && getImageCacheDirectory() 252 253 if (cacheDir && from.startsWith(cacheDir)) { 254 const to = joinPath(cacheDir, nanoid(36)) 255 256 await makeDirectoryAsync(cacheDir, {intermediates: true}) 257 await moveAsync({from, to}) 258 259 return to 260 } 261 262 return from 263} 264 265/** 266 * Copy a file from a potentially temporary location to our cache directory. 267 * This ensures picker files are available for draft saving even if the original 268 * temporary files are cleaned up by the OS. 269 * 270 * On web, converts blob URLs to data URIs immediately to prevent revocation issues. 271 */ 272async function copyToCache(from: string): Promise<string> { 273 // Data URIs don't need any conversion 274 if (from.startsWith('data:')) { 275 return from 276 } 277 278 if (IS_WEB) { 279 // Web: convert blob URLs to data URIs before they can be revoked 280 if (from.startsWith('blob:')) { 281 try { 282 const response = await fetch(from) 283 const blob = await response.blob() 284 return await blobToDataUri(blob) 285 } catch (e) { 286 // Blob URL was likely revoked, return as-is for downstream error handling 287 return from 288 } 289 } 290 // Other URLs on web don't need conversion 291 return from 292 } 293 294 // Native: copy to cache directory to survive OS temp file cleanup 295 const cacheDir = getImageCacheDirectory() 296 if (!cacheDir || from.startsWith(cacheDir)) { 297 return from 298 } 299 300 const to = joinPath(cacheDir, nanoid(36)) 301 await makeDirectoryAsync(cacheDir, {intermediates: true}) 302 303 let normalizedFrom = from 304 if (!from.startsWith('file://') && from.startsWith('/')) { 305 normalizedFrom = `file://${from}` 306 } 307 308 await copyAsync({from: normalizedFrom, to}) 309 return to 310} 311 312/** 313 * Convert a Blob to a data URI 314 */ 315function blobToDataUri(blob: Blob): Promise<string> { 316 return new Promise((resolve, reject) => { 317 const reader = new FileReader() 318 reader.onloadend = () => { 319 if (typeof reader.result === 'string') { 320 resolve(reader.result) 321 } else { 322 reject(new Error('Failed to convert blob to data URI')) 323 } 324 } 325 reader.onerror = () => reject(reader.error) 326 reader.readAsDataURL(blob) 327 }) 328} 329 330/** Purge files that were created to accomodate image manipulation */ 331export async function purgeTemporaryImageFiles() { 332 const cacheDir = IS_NATIVE && getImageCacheDirectory() 333 334 if (cacheDir) { 335 await deleteAsync(cacheDir, {idempotent: true}) 336 await makeDirectoryAsync(cacheDir) 337 } 338} 339 340function joinPath(a: string, b: string) { 341 if (a.endsWith('/')) { 342 if (b.startsWith('/')) { 343 return a.slice(0, -1) + b 344 } 345 return a + b 346 } else if (b.startsWith('/')) { 347 return a + b 348 } 349 return a + '/' + b 350} 351 352function containImageRes( 353 w: number, 354 h: number, 355 {width: maxW, height: maxH}: {width: number; height: number}, 356): [width: number, height: number] { 357 let scale = 1 358 359 if (w > maxW || h > maxH) { 360 scale = w > h ? maxW / w : maxH / h 361 w = Math.floor(w * scale) 362 h = Math.floor(h * scale) 363 } 364 365 return [w, h] 366}