forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}