1import {
2 downscalableImageMimeType,
3 fitImageAttachmentSize,
4} from "@t3tools/client-runtime/image-attachment-size";
5import {
6 IMAGE_DIMENSIONS_HEADER_BYTES,
7 readImageDimensions,
8} from "@t3tools/shared/imageDimensions";
9
10const 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 */
19export 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 */
52export 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}