mirror of https://git.lenooby09.tech/LeNooby09/social-app.git

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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 {
··· 26 img: PickerImage, 27 maxSize: number = POST_IMG_MAX.size, 28 ): Promise<PickerImage> { 29 + // Bypass all compression for maximum quality - return original image 30 + return img 31 } 32 33 export interface DownloadAndResizeOpts {
+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 {
··· 8 img: PickerImage, 9 maxSize: number, 10 ): Promise<PickerImage> { 11 + // Bypass all compression for maximum quality - return original image 12 + return img 13 } 14 15 export interface DownloadAndResizeOpts {
+3 -3
src/lib/media/picker.shared.ts
··· 8 import {isIOS, isWeb} from '#/platform/detection' 9 import {type ImageMeta} from '#/state/gallery' 10 import * as Toast from '#/view/com/util/Toast' 11 - import {VIDEO_MAX_DURATION_MS} from '../constants' 12 import {getDataUriSize} from './util' 13 14 export type PickerImage = ImageMeta & { ··· 54 allowsMultipleSelection: true, 55 legacy: true, 56 base64: isWeb, 57 - selectionLimit: isIOS ? selectionCountRemaining : undefined, 58 preferredAssetRepresentationMode: 59 UIImagePickerPreferredAssetRepresentationMode.Automatic, 60 - videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, 61 }) 62 }
··· 8 import {isIOS, isWeb} from '#/platform/detection' 9 import {type ImageMeta} from '#/state/gallery' 10 import * as Toast from '#/view/com/util/Toast' 11 import {getDataUriSize} from './util' 12 13 export type PickerImage = ImageMeta & { ··· 53 allowsMultipleSelection: true, 54 legacy: true, 55 base64: isWeb, 56 + // Remove iOS selection cap to allow unlimited selection 57 + selectionLimit: undefined, 58 preferredAssetRepresentationMode: 59 UIImagePickerPreferredAssetRepresentationMode.Automatic, 60 + // Remove video duration restriction 61 }) 62 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 4 UIImagePickerPreferredAssetRepresentationMode, 5 } from 'expo-image-picker' 6 7 8 export async function pickVideo() { 9 return await launchImageLibraryAsync({ ··· 13 legacy: true, 14 preferredAssetRepresentationMode: 15 UIImagePickerPreferredAssetRepresentationMode.Current, 16 }) 17 } 18