+1
-1
src/lib/api/index.ts
+1
-1
src/lib/api/index.ts
···
302
onStateChange?.(t`Uploading images...`)
303
const images: AppBskyEmbedImages.Image[] = await Promise.all(
304
imagesDraft.map(async (image, i) => {
305
-
logger.debug(`Compressing image #${i}`)
306
const {path, width, height, mime} = await compressImage(image)
307
logger.debug(`Uploading image #${i}`)
308
const res = await uploadBlob(agent, path, mime)
···
302
onStateChange?.(t`Uploading images...`)
303
const images: AppBskyEmbedImages.Image[] = await Promise.all(
304
imagesDraft.map(async (image, i) => {
305
+
logger.debug(`Processing image #${i}`)
306
const {path, width, height, mime} = await compressImage(image)
307
logger.debug(`Uploading image #${i}`)
308
const res = await uploadBlob(agent, path, mime)
+5
-5
src/lib/constants.ts
+5
-5
src/lib/constants.ts
···
94
]
95
96
export const POST_IMG_MAX = {
97
-
width: 2000,
98
-
height: 2000,
99
-
size: 1000000,
100
}
101
102
export const STAGING_LINK_META_PROXY =
···
183
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
184
/**
185
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
186
-
* ISO megabytes.
187
*/
188
-
export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb
189
190
export const SUPPORTED_MIME_TYPES = [
191
'video/mp4',
···
94
]
95
96
export const POST_IMG_MAX = {
97
+
width: 8000, // Increased from 2000 for high resolution support
98
+
height: 8000, // Increased from 2000 for high resolution support
99
+
size: 50000000, // Increased from 1MB to 50MB for maximum quality
100
}
101
102
export const STAGING_LINK_META_PROXY =
···
183
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
184
/**
185
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
186
+
* ISO megabytes. Increased for maximum quality support.
187
*/
188
+
export const VIDEO_MAX_SIZE = 1000 * 1000 * 500 // 500mb (increased from 100mb)
189
190
export const SUPPORTED_MIME_TYPES = [
191
'video/mp4',
+2
-18
src/lib/media/manip.ts
+2
-18
src/lib/media/manip.ts
···
26
img: PickerImage,
27
maxSize: number = POST_IMG_MAX.size,
28
): Promise<PickerImage> {
29
-
if (img.size < maxSize) {
30
-
return img
31
-
}
32
-
const resizedImage = await doResize(normalizePath(img.path), {
33
-
width: img.width,
34
-
height: img.height,
35
-
mode: 'stretch',
36
-
maxSize,
37
-
})
38
-
const finalImageMovedPath = await moveToPermanentPath(
39
-
resizedImage.path,
40
-
'.jpg',
41
-
)
42
-
const finalImg = {
43
-
...resizedImage,
44
-
path: finalImageMovedPath,
45
-
}
46
-
return finalImg
47
}
48
49
export interface DownloadAndResizeOpts {
+2
-9
src/lib/media/manip.web.ts
+2
-9
src/lib/media/manip.web.ts
···
8
img: PickerImage,
9
maxSize: number,
10
): Promise<PickerImage> {
11
-
if (img.size < maxSize) {
12
-
return img
13
-
}
14
-
return await doResize(img.path, {
15
-
width: img.width,
16
-
height: img.height,
17
-
mode: 'stretch',
18
-
maxSize,
19
-
})
20
}
21
22
export interface DownloadAndResizeOpts {
+3
-1
src/lib/media/picker.tsx
+3
-1
src/lib/media/picker.tsx
···
10
export async function openCamera(customOpts: ImagePickerOptions) {
11
const opts: ImagePickerOptions = {
12
mediaTypes: 'images',
13
...customOpts,
14
}
15
const res = await launchCameraAsync(opts)
···
32
export async function openCropper(opts: OpenCropperOptions) {
33
const item = await ExpoImageCropTool.openCropperAsync({
34
...opts,
35
-
format: 'jpeg',
36
})
37
38
return {
···
10
export async function openCamera(customOpts: ImagePickerOptions) {
11
const opts: ImagePickerOptions = {
12
mediaTypes: 'images',
13
+
quality: 1, // Maximum quality
14
+
allowsEditing: false, // Prevent quality loss from editing
15
...customOpts,
16
}
17
const res = await launchCameraAsync(opts)
···
34
export async function openCropper(opts: OpenCropperOptions) {
35
const item = await ExpoImageCropTool.openCropperAsync({
36
...opts,
37
+
format: 'png', // Use PNG instead of JPEG for lossless cropping
38
})
39
40
return {
+5
-38
src/lib/media/video/compress.ts
+5
-38
src/lib/media/video/compress.ts
···
14
onProgress?: (progress: number) => void
15
},
16
): Promise<CompressedVideo> {
17
-
const {onProgress, signal} = opts || {}
18
-
19
-
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
20
-
file.mimeType as SupportedMimeTypes,
21
-
)
22
-
23
-
if (file.mimeType === 'image/gif') {
24
-
// let's hope they're small enough that they don't need compression!
25
-
// this compression library doesn't support gifs
26
-
// worst case - server rejects them. I think that's fine -sfn
27
-
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
28
}
29
-
30
-
const minimumFileSizeForCompress = isAcceptableFormat
31
-
? MIN_SIZE_FOR_COMPRESSION
32
-
: 0
33
-
34
-
const compressed = await Video.compress(
35
-
file.uri,
36
-
{
37
-
compressionMethod: 'manual',
38
-
bitrate: 3_000_000, // 3mbps
39
-
maxSize: 1920,
40
-
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
41
-
minimumFileSizeForCompress,
42
-
getCancellationId: id => {
43
-
if (signal) {
44
-
signal.addEventListener('abort', () => {
45
-
Video.cancelCompression(id)
46
-
})
47
-
}
48
-
},
49
-
},
50
-
onProgress,
51
-
)
52
-
53
-
const info = await getVideoMetaData(compressed)
54
-
55
-
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
56
}
···
14
onProgress?: (progress: number) => void
15
},
16
): Promise<CompressedVideo> {
17
+
// Bypass all video compression for maximum quality - return original file
18
+
return {
19
+
uri: file.uri,
20
+
size: file.fileSize ?? -1,
21
+
mimeType: (file.mimeType as SupportedMimeTypes) || 'video/mp4'
22
}
23
}
+2
-5
src/lib/media/video/compress.web.ts
+2
-5
src/lib/media/video/compress.web.ts
···
4
import {VideoTooLargeError} from '#/lib/media/video/errors'
5
import {type CompressedVideo} from './types'
6
7
-
// doesn't actually compress, converts to ArrayBuffer
8
export async function compressVideo(
9
asset: ImagePickerAsset,
10
_opts?: {
···
16
const blob = base64ToBlob(base64, mimeType)
17
const uri = URL.createObjectURL(blob)
18
19
-
if (blob.size > VIDEO_MAX_SIZE) {
20
-
throw new VideoTooLargeError()
21
-
}
22
-
23
return {
24
size: blob.size,
25
uri,
···
4
import {VideoTooLargeError} from '#/lib/media/video/errors'
5
import {type CompressedVideo} from './types'
6
7
+
// Bypass compression completely - no size limits or processing
8
export async function compressVideo(
9
asset: ImagePickerAsset,
10
_opts?: {
···
16
const blob = base64ToBlob(base64, mimeType)
17
const uri = URL.createObjectURL(blob)
18
19
+
// No size limit check - allow any size for maximum quality
20
return {
21
size: blob.size,
22
uri,
+11
-58
src/state/gallery.ts
+11
-58
src/state/gallery.ts
···
12
} from 'expo-image-manipulator'
13
import {nanoid} from 'nanoid/non-secure'
14
15
-
import {POST_IMG_MAX} from '#/lib/constants'
16
import {getImageDim} from '#/lib/media/manip'
17
import {openCropper} from '#/lib/media/picker'
18
import {type PickerImage} from '#/lib/media/picker.shared'
19
-
import {getDataUriSize} from '#/lib/media/util'
20
import {isNative} from '#/platform/detection'
21
22
export type ImageTransformation = {
···
169
170
const source = img.source
171
const result = await manipulateAsync(source.path, actions, {
172
-
format: SaveFormat.PNG,
173
})
174
175
return {
···
198
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
199
const source = img.transformed || img.source
200
201
-
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
202
-
203
-
let minQualityPercentage = 0
204
-
let maxQualityPercentage = 101 // exclusive
205
-
let newDataUri
206
-
207
-
while (maxQualityPercentage - minQualityPercentage > 1) {
208
-
const qualityPercentage = Math.round(
209
-
(maxQualityPercentage + minQualityPercentage) / 2,
210
-
)
211
-
212
-
const res = await manipulateAsync(
213
-
source.path,
214
-
[{resize: {width: w, height: h}}],
215
-
{
216
-
compress: qualityPercentage / 100,
217
-
format: SaveFormat.JPEG,
218
-
base64: true,
219
-
},
220
-
)
221
-
222
-
const base64 = res.base64
223
-
const size = base64 ? getDataUriSize(base64) : 0
224
-
if (base64 && size <= POST_IMG_MAX.size) {
225
-
minQualityPercentage = qualityPercentage
226
-
newDataUri = {
227
-
path: await moveIfNecessary(res.uri),
228
-
width: res.width,
229
-
height: res.height,
230
-
mime: 'image/jpeg',
231
-
size,
232
-
}
233
-
} else {
234
-
maxQualityPercentage = qualityPercentage
235
-
}
236
}
237
-
238
-
if (newDataUri) {
239
-
return newDataUri
240
-
}
241
-
242
-
throw new Error(`Unable to compress image`)
243
}
244
245
async function moveIfNecessary(from: string) {
···
279
return a + '/' + b
280
}
281
282
-
function containImageRes(
283
-
w: number,
284
-
h: number,
285
-
{width: maxW, height: maxH}: {width: number; height: number},
286
-
): [width: number, height: number] {
287
-
let scale = 1
288
289
-
if (w > maxW || h > maxH) {
290
-
scale = w > h ? maxW / w : maxH / h
291
-
w = Math.floor(w * scale)
292
-
h = Math.floor(h * scale)
293
-
}
294
-
295
-
return [w, h]
296
-
}
···
12
} from 'expo-image-manipulator'
13
import {nanoid} from 'nanoid/non-secure'
14
15
import {getImageDim} from '#/lib/media/manip'
16
import {openCropper} from '#/lib/media/picker'
17
import {type PickerImage} from '#/lib/media/picker.shared'
18
import {isNative} from '#/platform/detection'
19
20
export type ImageTransformation = {
···
167
168
const source = img.source
169
const result = await manipulateAsync(source.path, actions, {
170
+
format: SaveFormat.PNG, // Keep PNG for lossless quality
171
+
compress: 1.0, // Maximum quality (no compression)
172
})
173
174
return {
···
197
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
198
const source = img.transformed || img.source
199
200
+
// Return the image without any compression - just move it if necessary
201
+
const path = await moveIfNecessary(source.path)
202
+
203
+
return {
204
+
path,
205
+
width: source.width,
206
+
height: source.height,
207
+
mime: source.mime,
208
+
size: 0, // Size will be calculated when uploading
209
}
210
}
211
212
async function moveIfNecessary(from: string) {
···
246
return a + '/' + b
247
}
248
249
+3
-2
src/view/com/composer/Composer.tsx
+3
-2
src/view/com/composer/Composer.tsx
···
1358
const media = post.embed.media
1359
const images = media?.type === 'images' ? media.images : []
1360
const video = media?.type === 'video' ? media.video : null
1361
-
const isMaxImages = images.length >= MAX_IMAGES
1362
-
const isMaxVideos = !!video
1363
1364
let selectedAssetsCount = 0
1365
let isMediaSelectionDisabled = false
···
1358
const media = post.embed.media
1359
const images = media?.type === 'images' ? media.images : []
1360
const video = media?.type === 'video' ? media.video : null
1361
+
// Allow unlimited images and multiple videos on the client side
1362
+
const isMaxImages = false
1363
+
const isMaxVideos = false
1364
1365
let selectedAssetsCount = 0
1366
let isMediaSelectionDisabled = false
+9
-99
src/view/com/composer/SelectMediaButton.tsx
+9
-99
src/view/com/composer/SelectMediaButton.tsx
···
1
import {useCallback} from 'react'
2
import {Keyboard} from 'react-native'
3
import {type ImagePickerAsset} from 'expo-image-picker'
4
-
import {msg, plural} from '@lingui/macro'
5
import {useLingui} from '@lingui/react'
6
7
-
import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
8
import {
9
usePhotoLibraryPermission,
10
useVideoLibraryPermission,
···
12
import {openUnifiedPicker} from '#/lib/media/picker'
13
import {extractDataUriMime} from '#/lib/media/util'
14
import {isNative, isWeb} from '#/platform/detection'
15
-
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
16
import {atoms as a, useTheme} from '#/alf'
17
import {Button} from '#/components/Button'
18
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
···
51
enum SelectedAssetError {
52
Unsupported = 'Unsupported',
53
MixedTypes = 'MixedTypes',
54
-
MaxImages = 'MaxImages',
55
-
MaxVideos = 'MaxVideos',
56
-
VideoTooLong = 'VideoTooLong',
57
-
FileTooBig = 'FileTooBig',
58
-
MaxGIFs = 'MaxGIFs',
59
}
60
61
/**
···
253
}
254
255
if (type === 'video') {
256
-
/**
257
-
* We don't care too much about mimeType at this point on native,
258
-
* since the `processVideo` step later on will convert to `.mp4`.
259
-
*/
260
-
if (isWeb && !isSupportedVideoMimeType(mimeType)) {
261
-
errors.add(SelectedAssetError.Unsupported)
262
-
continue
263
-
}
264
-
265
-
/*
266
-
* Filesize appears to be stable across all platforms, so we can use it
267
-
* to filter out large files on web. On native, we compress these anyway,
268
-
* so we only check on web.
269
-
*/
270
-
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
271
-
errors.add(SelectedAssetError.FileTooBig)
272
-
continue
273
-
}
274
}
275
276
if (type === 'image') {
277
-
if (!isSupportedImageMimeType(mimeType)) {
278
-
errors.add(SelectedAssetError.Unsupported)
279
-
continue
280
-
}
281
}
282
283
if (type === 'gif') {
284
-
/*
285
-
* Filesize appears to be stable across all platforms, so we can use it
286
-
* to filter out large files on web. On native, we compress GIFs as
287
-
* videos anyway, so we only check on web.
288
-
*/
289
-
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
290
-
errors.add(SelectedAssetError.FileTooBig)
291
-
continue
292
-
}
293
}
294
295
/*
···
310
})
311
}
312
313
-
if (supportedAssets.length > 0) {
314
-
if (selectableAssetType === 'image') {
315
-
if (supportedAssets.length > selectionCountRemaining) {
316
-
errors.add(SelectedAssetError.MaxImages)
317
-
supportedAssets = supportedAssets.slice(0, selectionCountRemaining)
318
-
}
319
-
} else if (selectableAssetType === 'video') {
320
-
if (supportedAssets.length > 1) {
321
-
errors.add(SelectedAssetError.MaxVideos)
322
-
supportedAssets = supportedAssets.slice(0, 1)
323
-
}
324
-
325
-
if (supportedAssets[0].duration) {
326
-
if (isWeb) {
327
-
/*
328
-
* Web reports duration as seconds
329
-
*/
330
-
supportedAssets[0].duration = supportedAssets[0].duration * 1000
331
-
}
332
-
333
-
if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) {
334
-
errors.add(SelectedAssetError.VideoTooLong)
335
-
supportedAssets = []
336
-
}
337
-
} else {
338
-
errors.add(SelectedAssetError.Unsupported)
339
-
supportedAssets = []
340
-
}
341
-
} else if (selectableAssetType === 'gif') {
342
-
if (supportedAssets.length > 1) {
343
-
errors.add(SelectedAssetError.MaxGIFs)
344
-
supportedAssets = supportedAssets.slice(0, 1)
345
-
}
346
-
}
347
-
}
348
349
return {
350
type: selectableAssetType!, // set above
···
365
const sheetWrapper = useSheetWrapper()
366
const t = useTheme()
367
368
-
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
369
370
const processSelectedAssets = useCallback(
371
async (rawAssets: ImagePickerAsset[]) => {
···
388
),
389
[SelectedAssetError.MixedTypes]: _(
390
msg`Selecting multiple media types is not supported.`,
391
-
),
392
-
[SelectedAssetError.MaxImages]: _(
393
-
msg({
394
-
message: `You can select up to ${plural(MAX_IMAGES, {
395
-
other: '# images',
396
-
})} in total.`,
397
-
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
398
-
}),
399
-
),
400
-
[SelectedAssetError.MaxVideos]: _(
401
-
msg`You can only select one video at a time.`,
402
-
),
403
-
[SelectedAssetError.VideoTooLong]: _(
404
-
msg`Videos must be less than 3 minutes long.`,
405
-
),
406
-
[SelectedAssetError.MaxGIFs]: _(
407
-
msg`You can only select one GIF at a time.`,
408
-
),
409
-
[SelectedAssetError.FileTooBig]: _(
410
-
msg`One or more of your selected files are too large. Maximum size is 100ย MB.`,
411
),
412
}[error]
413
})
···
472
)}
473
accessibilityHint={_(
474
msg({
475
-
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, {
476
-
other: '# images',
477
-
})}, or a single video or GIF.`,
478
-
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
479
}),
480
)}
481
style={a.p_sm}
···
1
import {useCallback} from 'react'
2
import {Keyboard} from 'react-native'
3
import {type ImagePickerAsset} from 'expo-image-picker'
4
+
import {msg} from '@lingui/macro'
5
import {useLingui} from '@lingui/react'
6
7
import {
8
usePhotoLibraryPermission,
9
useVideoLibraryPermission,
···
11
import {openUnifiedPicker} from '#/lib/media/picker'
12
import {extractDataUriMime} from '#/lib/media/util'
13
import {isNative, isWeb} from '#/platform/detection'
14
import {atoms as a, useTheme} from '#/alf'
15
import {Button} from '#/components/Button'
16
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
···
49
enum SelectedAssetError {
50
Unsupported = 'Unsupported',
51
MixedTypes = 'MixedTypes',
52
}
53
54
/**
···
246
}
247
248
if (type === 'video') {
249
+
// Accept all video/* mime types on all platforms without size checks
250
}
251
252
if (type === 'image') {
253
+
// Accept all image/* mime types without restriction
254
}
255
256
if (type === 'gif') {
257
+
// Accept GIFs without size restrictions
258
}
259
260
/*
···
275
})
276
}
277
278
+
// Allow unlimited assets of the selected type; do not enforce duration or count limits
279
280
return {
281
type: selectableAssetType!, // set above
···
296
const sheetWrapper = useSheetWrapper()
297
const t = useTheme()
298
299
+
// Allow effectively unlimited asset selection on the client side
300
+
const selectionCountRemaining = Number.MAX_SAFE_INTEGER
301
302
const processSelectedAssets = useCallback(
303
async (rawAssets: ImagePickerAsset[]) => {
···
320
),
321
[SelectedAssetError.MixedTypes]: _(
322
msg`Selecting multiple media types is not supported.`,
323
),
324
}[error]
325
})
···
384
)}
385
accessibilityHint={_(
386
msg({
387
+
message: `Opens device gallery to select media files.`,
388
+
comment: `Accessibility hint for button in composer to add images, videos, or GIFs to a post without specifying limits.`,
389
}),
390
)}
391
style={a.p_sm}
-2
src/view/com/composer/videos/pickVideo.ts
-2
src/view/com/composer/videos/pickVideo.ts
···
4
UIImagePickerPreferredAssetRepresentationMode,
5
} from 'expo-image-picker'
6
7
-
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
8
9
export async function pickVideo() {
10
return await launchImageLibraryAsync({
···
14
legacy: true,
15
preferredAssetRepresentationMode:
16
UIImagePickerPreferredAssetRepresentationMode.Current,
17
-
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
18
})
19
}
20