forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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(
208 img: ComposerImage,
209 options?: {
210 highResolution?: boolean
211 },
212): Promise<PickerImage> {
213 const source = img.transformed || img.source
214 const highResolution = options?.highResolution ?? false
215 let attempts = 0
216 let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width
217
218 let minQualityPercentage = 0
219 let maxQualityPercentage = 101 // exclusive
220 let newDataUri
221
222 while (maxQualityPercentage - minQualityPercentage > 1) {
223 if (attempts >= 4) break
224
225 const [w, h] = containImageRes(source.width, source.height, maxDimension)
226 const qualityPercentage = Math.round(
227 (maxQualityPercentage + minQualityPercentage) / 2,
228 )
229
230 /*
231 * In the event the image doesn't compress well, we want to avoid
232 * unecessary iterations. In this case, binary search will check 51, 26,
233 * 13(rounded). We don't want to go below 25, so if we've halved to 13,
234 * reset the loop and reduce the image dimensions instead.
235 */
236 if (qualityPercentage <= 13) {
237 minQualityPercentage = 0
238 maxQualityPercentage = 101
239 attempts++
240 // 4000px → 3200px → 2560px → 2048px → ~1638px
241 maxDimension = Math.floor(maxDimension * 0.8)
242 continue
243 }
244
245 const res = await manipulateAsync(
246 source.path,
247 [{resize: {width: w, height: h}}],
248 {
249 compress: qualityPercentage / 100,
250 format: SaveFormat.JPEG,
251 base64: true,
252 },
253 )
254
255 const base64 = res.base64
256 const size = base64 ? getDataUriSize(base64) : 0
257 if (base64 && size <= POST_IMG_MAX.size) {
258 minQualityPercentage = qualityPercentage
259 newDataUri = {
260 path: await moveIfNecessary(res.uri),
261 width: res.width,
262 height: res.height,
263 mime: 'image/jpeg',
264 size,
265 }
266 } else {
267 maxQualityPercentage = qualityPercentage
268 }
269 }
270
271 if (newDataUri) {
272 return newDataUri
273 }
274
275 throw new Error(`Unable to compress image`)
276}
277
278async function moveIfNecessary(from: string) {
279 const cacheDir = IS_NATIVE && getImageCacheDirectory()
280
281 if (cacheDir && from.startsWith(cacheDir)) {
282 const to = joinPath(cacheDir, nanoid(36))
283
284 await makeDirectoryAsync(cacheDir, {intermediates: true})
285 await moveAsync({from, to})
286
287 return to
288 }
289
290 return from
291}
292
293/**
294 * Copy a file from a potentially temporary location to our cache directory.
295 * This ensures picker files are available for draft saving even if the original
296 * temporary files are cleaned up by the OS.
297 *
298 * On web, converts blob URLs to data URIs immediately to prevent revocation issues.
299 */
300async function copyToCache(from: string): Promise<string> {
301 // Data URIs don't need any conversion
302 if (from.startsWith('data:')) {
303 return from
304 }
305
306 if (IS_WEB) {
307 // Web: convert blob URLs to data URIs before they can be revoked
308 if (from.startsWith('blob:')) {
309 try {
310 const response = await fetch(from)
311 const blob = await response.blob()
312 return await blobToDataUri(blob)
313 } catch (e) {
314 // Blob URL was likely revoked, return as-is for downstream error handling
315 return from
316 }
317 }
318 // Other URLs on web don't need conversion
319 return from
320 }
321
322 // Native: copy to cache directory to survive OS temp file cleanup
323 const cacheDir = getImageCacheDirectory()
324 if (!cacheDir || from.startsWith(cacheDir)) {
325 return from
326 }
327
328 const to = joinPath(cacheDir, nanoid(36))
329 await makeDirectoryAsync(cacheDir, {intermediates: true})
330
331 let normalizedFrom = from
332 if (!from.startsWith('file://') && from.startsWith('/')) {
333 normalizedFrom = `file://${from}`
334 }
335
336 await copyAsync({from: normalizedFrom, to})
337 return to
338}
339
340/**
341 * Convert a Blob to a data URI
342 */
343function blobToDataUri(blob: Blob): Promise<string> {
344 return new Promise((resolve, reject) => {
345 const reader = new FileReader()
346 reader.onloadend = () => {
347 if (typeof reader.result === 'string') {
348 resolve(reader.result)
349 } else {
350 reject(new Error('Failed to convert blob to data URI'))
351 }
352 }
353 reader.onerror = () => reject(reader.error)
354 reader.readAsDataURL(blob)
355 })
356}
357
358/** Purge files that were created to accomodate image manipulation */
359export async function purgeTemporaryImageFiles() {
360 const cacheDir = IS_NATIVE && getImageCacheDirectory()
361
362 if (cacheDir) {
363 await deleteAsync(cacheDir, {idempotent: true})
364 await makeDirectoryAsync(cacheDir)
365 }
366}
367
368function joinPath(a: string, b: string) {
369 if (a.endsWith('/')) {
370 if (b.startsWith('/')) {
371 return a.slice(0, -1) + b
372 }
373 return a + b
374 } else if (b.startsWith('/')) {
375 return a + b
376 }
377 return a + '/' + b
378}
379
380function containImageRes(
381 w: number,
382 h: number,
383 max: number,
384): [width: number, height: number] {
385 let scale = 1
386
387 if (w > max || h > max) {
388 scale = w > h ? max / w : max / h
389 w = Math.floor(w * scale)
390 h = Math.floor(h * scale)
391 }
392
393 return [w, h]
394}