Files
bsky-social-app/src/state/gallery.ts
T
2026-09-04 06:59:55 -07:00

413 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
cacheDirectory,
copyAsync,
deleteAsync,
makeDirectoryAsync,
moveAsync,
} from 'expo-file-system/legacy'
import {type ImageManipulatorContext, SaveFormat} from 'expo-image-manipulator'
import {nanoid} from 'nanoid/non-secure'
import {renderImage} from '#/lib/media/image-manipulator'
import {getImageDim} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
import {getDataUriSize} from '#/lib/media/util'
import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {IS_NATIVE, IS_WEB} from '#/env'
export type ImageTransformation = {
crop?: Parameters<ImageManipulatorContext['crop']>[0]
}
export type ImageMeta = {
path: string
width: number
height: number
mime: string
}
export type ImageSource = ImageMeta & {
id: string
}
type ComposerImageBase = {
alt: string
source: ImageSource
/** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */
localRefPath?: string
}
type ComposerImageWithoutTransformation = ComposerImageBase & {
transformed?: undefined
manips?: undefined
}
type ComposerImageWithTransformation = ComposerImageBase & {
transformed: ImageMeta
manips?: ImageTransformation
}
export type ComposerImage =
ComposerImageWithoutTransformation | ComposerImageWithTransformation
let _imageCacheDirectory: string
function getImageCacheDirectory(): string | null {
if (IS_NATIVE) {
return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
}
return null
}
export async function createComposerImage(
raw: ImageMeta,
): Promise<ComposerImageWithoutTransformation> {
return {
alt: '',
source: {
id: nanoid(),
// Copy to cache to ensure file survives OS temporary file cleanup
path: await copyToCache(raw.path),
width: raw.width,
height: raw.height,
mime: raw.mime,
},
}
}
export type InitialImage = {
uri: string
width: number
height: number
altText?: string
}
export function createInitialImages(
uris: InitialImage[] = [],
): ComposerImageWithoutTransformation[] {
return uris.map(({uri, width, height, altText = ''}) => {
return {
alt: altText,
source: {
id: nanoid(),
path: uri,
width: width,
height: height,
mime: 'image/jpeg',
},
}
})
}
export async function pasteImage(
uri: string,
): Promise<ComposerImageWithoutTransformation> {
const {width, height} = await getImageDim(uri)
const match = /^data:(.+?);/.exec(uri)
return {
alt: '',
source: {
id: nanoid(),
path: uri,
width: width,
height: height,
mime: match ? match[1] : 'image/jpeg',
},
}
}
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
if (!IS_NATIVE) {
return img
}
const source = img.source
// @todo: we're always passing the original image here, does image-cropper
// allows for setting initial crop dimensions? -mary
try {
const cropped = await openCropper({
imageUri: source.path,
})
return {
alt: img.alt,
source: source,
transformed: {
path: await moveIfNecessary(cropped.path),
width: cropped.width,
height: cropped.height,
mime: cropped.mime,
},
}
} catch (e) {
if (!isCancelledError(e)) {
logger.error('Failed to crop image', {safeMessage: e})
return img
}
throw e
}
}
export async function manipulateImage(
img: ComposerImage,
trans: ImageTransformation,
): Promise<ComposerImage> {
const crop = trans.crop
if (!crop) {
if (img.transformed === undefined) {
return img
}
return {alt: img.alt, source: img.source}
}
const source = img.source
const result = await renderImage(source.path, context => context.crop(crop), {
format: SaveFormat.PNG,
})
return {
alt: img.alt,
source: img.source,
transformed: {
path: await moveIfNecessary(result.uri),
width: result.width,
height: result.height,
mime: 'image/png',
},
manips: trans,
}
}
export function resetImageManipulation(
img: ComposerImage,
): ComposerImageWithoutTransformation {
if (img.transformed !== undefined) {
return {alt: img.alt, source: img.source}
}
return img
}
export async function compressImage(
img: ComposerImage,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> {
const source = img.transformed || img.source
let attempts = 0
// Seeded from `maxDimension` but shrunk per attempt below, so keep the
// passed-in value pristine.
let currentDimension = maxDimension
const maxBytes = maxSize
let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive
let newDataUri
while (maxQualityPercentage - minQualityPercentage > 1) {
if (attempts >= 4) break
const [w, h] = containImageRes(
source.width,
source.height,
currentDimension,
)
const qualityPercentage = Math.round(
(maxQualityPercentage + minQualityPercentage) / 2,
)
/*
* In the event the image doesn't compress well, we want to avoid
* unecessary iterations. In this case, binary search will check 51, 26,
* 13(rounded). We don't want to go below 25, so if we've halved to 13,
* reset the loop and reduce the image dimensions instead.
*/
if (qualityPercentage <= 13) {
minQualityPercentage = 0
maxQualityPercentage = 101
attempts++
// max.width → 0.8× → 0.64× → 0.512× → ~0.41×
// e.g. 4000px → 3200px → 2560px → 2048px → ~1638px
currentDimension = Math.floor(currentDimension * 0.8)
continue
}
const res = await renderImage(
source.path,
context => context.resize({width: w, height: h}),
{
compress: qualityPercentage / 100,
format: SaveFormat.JPEG,
base64: true,
},
)
const base64 = res.base64
const size = base64 ? getDataUriSize(base64) : 0
if (base64 && size <= maxBytes) {
minQualityPercentage = qualityPercentage
newDataUri = {
path: await moveIfNecessary(res.uri),
width: res.width,
height: res.height,
mime: 'image/jpeg',
size,
}
} else {
maxQualityPercentage = qualityPercentage
}
}
if (newDataUri) {
return newDataUri
}
throw new Error(`Unable to compress image`)
}
async function moveIfNecessary(from: string) {
const cacheDir = IS_NATIVE && getImageCacheDirectory()
if (cacheDir && !from.startsWith(cacheDir)) {
const to = joinPath(cacheDir, nanoid(36))
await makeDirectoryAsync(cacheDir, {intermediates: true})
await moveAsync({from, to})
return to
}
return from
}
/**
* Copy a file from a potentially temporary location to our cache directory.
* This ensures picker files are available for draft saving even if the original
* temporary files are cleaned up by the OS.
*
* On web, converts blob URLs to data URIs immediately to prevent revocation issues.
*/
async function copyToCache(from: string): Promise<string> {
// Data URIs don't need any conversion
if (from.startsWith('data:')) {
return from
}
if (IS_WEB) {
// Web: convert blob URLs to data URIs before they can be revoked
if (from.startsWith('blob:')) {
try {
const response = await fetch(from)
const blob = await response.blob()
return await blobToDataUri(blob)
} catch (e) {
// Blob URL was likely revoked, return as-is for downstream error handling
return from
}
}
// Other URLs on web don't need conversion
return from
}
// Native: copy to cache directory to survive OS temp file cleanup
const cacheDir = getImageCacheDirectory()
if (!cacheDir || from.startsWith(cacheDir)) {
return from
}
const to = joinPath(cacheDir, nanoid(36))
await makeDirectoryAsync(cacheDir, {intermediates: true})
let normalizedFrom = from
if (!from.startsWith('file://') && from.startsWith('/')) {
normalizedFrom = `file://${from}`
}
await copyAsync({from: normalizedFrom, to})
return to
}
/**
* Convert a Blob to a data URI
*/
function blobToDataUri(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert blob to data URI'))
}
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(blob)
})
}
/**
* Caches that the OS image picker and manipulator write into when attaching
* media to a post. They live alongside our own `bsky-composer` dir under the OS
* cache directory. expo-image-picker copies every originally selected photo and
* video here, and expo-image-manipulator leaves intermediate full-resolution
* outputs here (compressImage makes several rendering passes, only the last of
* which gets moved into `bsky-composer`). Nothing else cleans these up,
* so on iOS - where the OS exposes no "clear cache" - they accumulate
* indefinitely, one full-resolution copy per attached item.
*/
const SYSTEM_MEDIA_CACHE_DIRS = ['ImagePicker', 'ImageManipulator']
/** Purge files that were created to accomodate image manipulation */
export async function purgeTemporaryImageFiles() {
if (!IS_NATIVE) {
return
}
const cacheDir = getImageCacheDirectory()
if (cacheDir) {
await deleteAsync(cacheDir, {idempotent: true})
await makeDirectoryAsync(cacheDir)
}
// We don't recreate these - the respective expo modules recreate them on
// demand the next time they run.
await Promise.all(
SYSTEM_MEDIA_CACHE_DIRS.map(dir =>
deleteAsync(joinPath(cacheDirectory!, dir), {idempotent: true}),
),
)
}
function joinPath(a: string, b: string) {
if (a.endsWith('/')) {
if (b.startsWith('/')) {
return a.slice(0, -1) + b
}
return a + b
} else if (b.startsWith('/')) {
return a + b
}
return a + '/' + b
}
function containImageRes(
w: number,
h: number,
max: number,
): [width: number, height: number] {
let scale = 1
if (w > max || h > max) {
scale = w > h ? max / w : max / h
w = Math.floor(w * scale)
h = Math.floor(h * scale)
}
return [w, h]
}