Bluesky app fork with some witchin' additions 馃挮
at main 369 lines 8.8 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 {type BlobRef} from '@atproto/api' 15import {nanoid} from 'nanoid/non-secure' 16 17import {POST_IMG_MAX} from '#/lib/constants' 18import {getImageDim} from '#/lib/media/manip' 19import {openCropper} from '#/lib/media/picker' 20import {type PickerImage} from '#/lib/media/picker.shared' 21import {getDataUriSize} from '#/lib/media/util' 22import {isCancelledError} from '#/lib/strings/errors' 23import {IS_NATIVE, IS_WEB} from '#/env' 24 25export type ImageTransformation = { 26 crop?: ActionCrop['crop'] 27} 28 29export type ImageMeta = { 30 path: string 31 width: number 32 height: number 33 mime: string 34} 35 36export type ImageSource = ImageMeta & { 37 id: string 38} 39 40type ComposerImageBase = { 41 alt: string 42 source: ImageSource 43 blobRef?: BlobRef 44 /** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */ 45 localRefPath?: string 46} 47type ComposerImageWithoutTransformation = ComposerImageBase & { 48 transformed?: undefined 49 manips?: undefined 50} 51type ComposerImageWithTransformation = ComposerImageBase & { 52 transformed: ImageMeta 53 manips?: ImageTransformation 54} 55 56export type ComposerImage = 57 | ComposerImageWithoutTransformation 58 | ComposerImageWithTransformation 59 60let _imageCacheDirectory: string 61 62function getImageCacheDirectory(): string | null { 63 if (IS_NATIVE) { 64 return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 65 } 66 67 return null 68} 69 70export async function createComposerImage( 71 raw: ImageMeta, 72): Promise<ComposerImageWithoutTransformation> { 73 return { 74 alt: '', 75 source: { 76 id: nanoid(), 77 // Copy to cache to ensure file survives OS temporary file cleanup 78 path: await copyToCache(raw.path), 79 width: raw.width, 80 height: raw.height, 81 mime: raw.mime, 82 }, 83 } 84} 85 86export type InitialImage = { 87 uri: string 88 width: number 89 height: number 90 altText?: string 91 blobRef?: BlobRef 92} 93 94export function createInitialImages( 95 uris: InitialImage[] = [], 96): ComposerImageWithoutTransformation[] { 97 return uris.map(({uri, width, height, altText = '', blobRef}) => { 98 return { 99 alt: altText, 100 source: { 101 id: nanoid(), 102 path: uri, 103 width: width, 104 height: height, 105 mime: 'image/jpeg', 106 }, 107 blobRef, 108 } 109 }) 110} 111 112export async function pasteImage( 113 uri: string, 114): Promise<ComposerImageWithoutTransformation> { 115 const {width, height} = await getImageDim(uri) 116 const match = /^data:(.+?);/.exec(uri) 117 118 return { 119 alt: '', 120 source: { 121 id: nanoid(), 122 path: uri, 123 width: width, 124 height: height, 125 mime: match ? match[1] : 'image/jpeg', 126 }, 127 } 128} 129 130export async function cropImage(img: ComposerImage): Promise<ComposerImage> { 131 if (!IS_NATIVE) { 132 return img 133 } 134 135 const source = img.source 136 137 // @todo: we're always passing the original image here, does image-cropper 138 // allows for setting initial crop dimensions? -mary 139 try { 140 const cropped = await openCropper({ 141 imageUri: source.path, 142 }) 143 144 return { 145 alt: img.alt, 146 source: source, 147 transformed: { 148 path: await moveIfNecessary(cropped.path), 149 width: cropped.width, 150 height: cropped.height, 151 mime: cropped.mime, 152 }, 153 } 154 } catch (e) { 155 if (!isCancelledError(e)) { 156 return img 157 } 158 159 throw e 160 } 161} 162 163export async function manipulateImage( 164 img: ComposerImage, 165 trans: ImageTransformation, 166): Promise<ComposerImage> { 167 const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] 168 169 const actions = rawActions.filter((a): a is Action => a !== undefined) 170 171 if (actions.length === 0) { 172 if (img.transformed === undefined) { 173 return img 174 } 175 176 return {alt: img.alt, source: img.source} 177 } 178 179 const source = img.source 180 const result = await manipulateAsync(source.path, actions, { 181 format: SaveFormat.PNG, 182 }) 183 184 return { 185 alt: img.alt, 186 source: img.source, 187 transformed: { 188 path: await moveIfNecessary(result.uri), 189 width: result.width, 190 height: result.height, 191 mime: 'image/png', 192 }, 193 manips: trans, 194 } 195} 196 197export function resetImageManipulation( 198 img: ComposerImage, 199): ComposerImageWithoutTransformation { 200 if (img.transformed !== undefined) { 201 return {alt: img.alt, source: img.source} 202 } 203 204 return img 205} 206 207export async function compressImage(img: ComposerImage): Promise<PickerImage> { 208 const source = img.transformed || img.source 209 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 210 211 let minQualityPercentage = 0 212 let maxQualityPercentage = 101 // exclusive 213 let newDataUri 214 215 while (maxQualityPercentage - minQualityPercentage > 1) { 216 const qualityPercentage = Math.round( 217 (maxQualityPercentage + minQualityPercentage) / 2, 218 ) 219 220 const res = await manipulateAsync( 221 source.path, 222 [{resize: {width: w, height: h}}], 223 { 224 compress: qualityPercentage / 100, 225 format: SaveFormat.JPEG, 226 base64: true, 227 }, 228 ) 229 230 const base64 = res.base64 231 const size = base64 ? getDataUriSize(base64) : 0 232 if (base64 && size <= POST_IMG_MAX.size) { 233 minQualityPercentage = qualityPercentage 234 newDataUri = { 235 path: await moveIfNecessary(res.uri), 236 width: res.width, 237 height: res.height, 238 mime: 'image/jpeg', 239 size, 240 } 241 } else { 242 maxQualityPercentage = qualityPercentage 243 } 244 } 245 246 if (newDataUri) { 247 return newDataUri 248 } 249 250 throw new Error(`Unable to compress image`) 251} 252 253async function moveIfNecessary(from: string) { 254 const cacheDir = IS_NATIVE && getImageCacheDirectory() 255 256 if (cacheDir && from.startsWith(cacheDir)) { 257 const to = joinPath(cacheDir, nanoid(36)) 258 259 await makeDirectoryAsync(cacheDir, {intermediates: true}) 260 await moveAsync({from, to}) 261 262 return to 263 } 264 265 return from 266} 267 268/** 269 * Copy a file from a potentially temporary location to our cache directory. 270 * This ensures picker files are available for draft saving even if the original 271 * temporary files are cleaned up by the OS. 272 * 273 * On web, converts blob URLs to data URIs immediately to prevent revocation issues. 274 */ 275async function copyToCache(from: string): Promise<string> { 276 // Data URIs don't need any conversion 277 if (from.startsWith('data:')) { 278 return from 279 } 280 281 if (IS_WEB) { 282 // Web: convert blob URLs to data URIs before they can be revoked 283 if (from.startsWith('blob:')) { 284 try { 285 const response = await fetch(from) 286 const blob = await response.blob() 287 return await blobToDataUri(blob) 288 } catch (e) { 289 // Blob URL was likely revoked, return as-is for downstream error handling 290 return from 291 } 292 } 293 // Other URLs on web don't need conversion 294 return from 295 } 296 297 // Native: copy to cache directory to survive OS temp file cleanup 298 const cacheDir = getImageCacheDirectory() 299 if (!cacheDir || from.startsWith(cacheDir)) { 300 return from 301 } 302 303 const to = joinPath(cacheDir, nanoid(36)) 304 await makeDirectoryAsync(cacheDir, {intermediates: true}) 305 306 let normalizedFrom = from 307 if (!from.startsWith('file://') && from.startsWith('/')) { 308 normalizedFrom = `file://${from}` 309 } 310 311 await copyAsync({from: normalizedFrom, to}) 312 return to 313} 314 315/** 316 * Convert a Blob to a data URI 317 */ 318function blobToDataUri(blob: Blob): Promise<string> { 319 return new Promise((resolve, reject) => { 320 const reader = new FileReader() 321 reader.onloadend = () => { 322 if (typeof reader.result === 'string') { 323 resolve(reader.result) 324 } else { 325 reject(new Error('Failed to convert blob to data URI')) 326 } 327 } 328 reader.onerror = () => reject(reader.error) 329 reader.readAsDataURL(blob) 330 }) 331} 332 333/** Purge files that were created to accomodate image manipulation */ 334export async function purgeTemporaryImageFiles() { 335 const cacheDir = IS_NATIVE && getImageCacheDirectory() 336 337 if (cacheDir) { 338 await deleteAsync(cacheDir, {idempotent: true}) 339 await makeDirectoryAsync(cacheDir) 340 } 341} 342 343function joinPath(a: string, b: string) { 344 if (a.endsWith('/')) { 345 if (b.startsWith('/')) { 346 return a.slice(0, -1) + b 347 } 348 return a + b 349 } else if (b.startsWith('/')) { 350 return a + b 351 } 352 return a + '/' + b 353} 354 355function containImageRes( 356 w: number, 357 h: number, 358 {width: maxW, height: maxH}: {width: number; height: number}, 359): [width: number, height: number] { 360 let scale = 1 361 362 if (w > maxW || h > maxH) { 363 scale = w > h ? maxW / w : maxH / h 364 w = Math.floor(w * scale) 365 h = Math.floor(h * scale) 366 } 367 368 return [w, h] 369}