Files
bsky-social-app/src/lib/media/manip.web.ts
T
Eric Bailey 795d493dea Apply per-use-case image size configs (#10690)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:04 -05:00

195 lines
4.8 KiB
TypeScript

import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
import {
blobToDataUri,
convertCdnPreset,
getDataUriSize,
getResizedDimensions,
} from './util'
export async function compressIfNeeded(
img: PickerImage,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> {
if (img.size < maxSize) {
return img
}
return await doResize(img.path, {
maxDimension,
maxSize,
})
}
export interface DownloadAndResizeOpts {
uri: string
maxDimension: number
maxSize: number
timeout: number
}
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
const res = await fetch(opts.uri)
const resBody = await res.blob()
clearTimeout(to)
const dataUri = await blobToDataUri(resBody)
return await doResize(dataUri, {
maxDimension: opts.maxDimension,
maxSize: opts.maxSize,
})
}
export async function shareImageModal(_opts: {uri: string}) {
// TODO
throw new Error('TODO')
}
/**
* Saves an image to the user's device. Uses the CDN's `download` preset
* which uses the JPEG version with the Content-Disposition header set to
* `attachment; filename=<filename>`. On native this saves to the media library;
* on web it triggers a browser download.
*/
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
const downloadUri = convertCdnPreset(uri, 'download')
const segments = downloadUri.split('/')
const filename = `bluesky-${segments.at(-1)}.jpg`
downloadUrl(downloadUri, filename)
}
export async function getImageDim(path: string): Promise<Dimensions> {
var img = document.createElement('img')
const promise = new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
img.src = path
await promise
return {width: img.width, height: img.height}
}
// internal methods
// =
interface DoResizeOpts {
maxDimension: number
maxSize: number
}
async function doResize(
dataUri: string,
opts: DoResizeOpts,
): Promise<PickerImage> {
const sourceDims = await getImageDim(dataUri)
const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension)
let newDataUri
let minQualityPercentage = 0
let maxQualityPercentage = 101 //exclusive
while (maxQualityPercentage - minQualityPercentage > 1) {
const qualityPercentage = Math.round(
(maxQualityPercentage + minQualityPercentage) / 2,
)
const tempDataUri = await createResizedImage(dataUri, {
width: newDimensions.width,
height: newDimensions.height,
quality: qualityPercentage / 100,
mode: 'contain',
})
if (getDataUriSize(tempDataUri) < opts.maxSize) {
minQualityPercentage = qualityPercentage
newDataUri = tempDataUri
} else {
maxQualityPercentage = qualityPercentage
}
}
if (!newDataUri) {
throw new Error('Failed to compress image')
}
return {
path: newDataUri,
mime: 'image/jpeg',
size: getDataUriSize(newDataUri),
width: newDimensions.width,
height: newDimensions.height,
}
}
function createResizedImage(
dataUri: string,
{
width,
height,
quality,
mode,
}: {
width: number
height: number
quality: number
mode: 'contain' | 'cover' | 'stretch'
},
): Promise<string> {
return new Promise((resolve, reject) => {
const img = document.createElement('img')
img.addEventListener('load', () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
return reject(new Error('Failed to resize image'))
}
let scale = 1
if (mode === 'cover') {
scale = img.width < img.height ? width / img.width : height / img.height
} else if (mode === 'contain') {
scale = img.width > img.height ? width / img.width : height / img.height
}
let w = img.width * scale
let h = img.height * scale
canvas.width = w
canvas.height = h
ctx.drawImage(img, 0, 0, w, h)
resolve(canvas.toDataURL('image/jpeg', quality))
})
img.addEventListener('error', ev => {
reject(ev.error)
})
img.src = dataUri
})
}
export async function saveBytesToDisk(
filename: string,
bytes: Uint8Array<ArrayBuffer>,
type: string,
) {
const blob = new Blob([bytes], {type})
const url = URL.createObjectURL(blob)
downloadUrl(url, filename)
// Firefox requires a small delay
setTimeout(() => URL.revokeObjectURL(url), 100)
return true
}
function downloadUrl(href: string, filename: string) {
const a = document.createElement('a')
a.href = href
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
export async function safeDeleteAsync() {
// no-op
}