| 1 | import { |
| 2 | downscalableImageMimeType, |
| 3 | fitImageAttachmentSize, |
| 4 | } from "@t3tools/client-runtime/image-attachment-size"; |
| 5 | import { |
| 6 | IMAGE_DIMENSIONS_HEADER_BYTES, |
| 7 | readImageDimensions, |
| 8 | } from "@t3tools/shared/imageDimensions"; |
| 9 | |
| 10 | const PHOTO_JPEG_QUALITY = 0.85; |
| 11 | |
| 12 | /** |
| 13 | * Renders an image to a JPEG, or a PNG when `format` asks for one, with its long edge capped at |
| 14 | * `IMAGE_ATTACHMENT_MAX_EDGE`. Decode, downscale, |
| 15 | * and encode run natively; only the bounded result crosses the bridge. Camera photos are |
| 16 | * 12-48 MP HEIC files, so a full-size conversion is both slow to transfer and far more than a |
| 17 | * model can use. |
| 18 | */ |
| 19 | export async function renderPhoto( |
| 20 | uri: string, |
| 21 | format: "jpeg" | "png" = "jpeg", |
| 22 | ): Promise<{ base64: string; uri: string }> { |
| 23 | const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator"); |
| 24 | let image = await ImageManipulator.manipulate(uri).renderAsync(); |
| 25 | try { |
| 26 | const fittedSize = fitImageAttachmentSize(image); |
| 27 | if (fittedSize !== null) { |
| 28 | const resized = await ImageManipulator.manipulate(image).resize(fittedSize).renderAsync(); |
| 29 | image.release(); |
| 30 | image = resized; |
| 31 | } |
| 32 | const saved = await image.saveAsync({ |
| 33 | format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, |
| 34 | compress: PHOTO_JPEG_QUALITY, |
| 35 | base64: true, |
| 36 | }); |
| 37 | if (!saved.base64) { |
| 38 | throw new Error("The rendered photo has no bytes."); |
| 39 | } |
| 40 | return { base64: saved.base64, uri: saved.uri }; |
| 41 | } finally { |
| 42 | image.release(); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Scales a PNG or JPEG whose long edge is over `IMAGE_ATTACHMENT_MAX_EDGE` down to that edge |
| 48 | * in its own format. Returns null when the image fits, is another format (GIF, WebP), or the |
| 49 | * native renderer fails, so the caller keeps the original bytes. Images inside the limit are |
| 50 | * never re-encoded: only the header is read to decide. |
| 51 | */ |
| 52 | export async function fitComposerImage(image: { |
| 53 | readonly uri: string; |
| 54 | readonly mimeType: string; |
| 55 | readonly base64: string; |
| 56 | }): Promise<{ base64: string; uri: string } | null> { |
| 57 | const mimeType = downscalableImageMimeType(image.mimeType); |
| 58 | if (mimeType === null) return null; |
| 59 | try { |
| 60 | const header = atob(image.base64.slice(0, Math.ceil(IMAGE_DIMENSIONS_HEADER_BYTES / 3) * 4)); |
| 61 | const bytes = new Uint8Array(header.length); |
| 62 | for (let index = 0; index < header.length; index += 1) bytes[index] = header.charCodeAt(index); |
| 63 | const dimensions = readImageDimensions(bytes); |
| 64 | if (dimensions === null || fitImageAttachmentSize(dimensions) === null) return null; |
| 65 | return await renderPhoto(image.uri, mimeType === "image/png" ? "png" : "jpeg"); |
| 66 | } catch (error) { |
| 67 | console.warn("[composer-images] could not scale an oversized image", error); |
| 68 | return null; |
| 69 | } |
| 70 | } |