1import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
2import {
3 downscalableImageMimeType,
4 fitImageAttachmentSize,
5} from "@t3tools/client-runtime/image-attachment-size";
6import {
7 IMAGE_DIMENSIONS_HEADER_BYTES,
8 readImageDimensions,
9} from "@t3tools/shared/imageDimensions";
10import { estimateBase64ByteSize } from "./base64";
11
12const PHOTO_JPEG_QUALITY = 0.85;
13const MAX_BYTE_LIMIT_SHRINKS = 3;
14
15function shrinkToByteLimit(
16 size: { readonly width: number; readonly height: number },
17 bytes: number,
18): { width: number; height: number } | null {
19 if (bytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) return null;
20 // Encoded size tracks pixel count, so scale each edge by the square root with some headroom.
21 const scale = Math.sqrt(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / bytes) * 0.9;
22 const next = {
23 width: Math.max(1, Math.floor(size.width * scale)),
24 height: Math.max(1, Math.floor(size.height * scale)),
25 };
26 return next.width < size.width || next.height < size.height ? next : null;
27}
28
29export async function renderPhoto(
30 uri: string,
31 format: "jpeg" | "png" = "jpeg",
32): Promise<{ base64: string; uri: string }> {
33 const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator");
34 let image = await ImageManipulator.manipulate(uri).renderAsync();
35 try {
36 let size = fitImageAttachmentSize(image);
37 for (let shrinks = 0; ; shrinks += 1) {
38 if (size !== null) {
39 const resized = await ImageManipulator.manipulate(image).resize(size).renderAsync();
40 image.release();
41 image = resized;
42 }
43 const saved = await image.saveAsync({
44 format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG,
45 compress: PHOTO_JPEG_QUALITY,
46 base64: true,
47 });
48 if (!saved.base64) {
49 throw new Error("The rendered photo has no bytes.");
50 }
51 // PNG ignores compress, so a resampled PNG can encode larger than its original.
52 size =
53 shrinks < MAX_BYTE_LIMIT_SHRINKS
54 ? shrinkToByteLimit(image, estimateBase64ByteSize(saved.base64))
55 : null;
56 if (size === null) return { base64: saved.base64, uri: saved.uri };
57 try {
58 const { File } = await import("expo-file-system");
59 const discarded = new File(saved.uri);
60 if (discarded.exists) discarded.delete();
61 } catch (error) {
62 console.warn("[composer-images] could not remove discarded render", error);
63 }
64 }
65 } finally {
66 image.release();
67 }
68}
69
70/** Returns null to keep original bytes if they fit, use another format, or native rendering fails. */
71export async function fitComposerImage(image: {
72 readonly uri: string;
73 readonly mimeType: string;
74 readonly base64: string;
75}): Promise<{ base64: string; uri: string } | null> {
76 const mimeType = downscalableImageMimeType(image.mimeType);
77 if (mimeType === null) return null;
78 try {
79 const header = atob(image.base64.slice(0, Math.ceil(IMAGE_DIMENSIONS_HEADER_BYTES / 3) * 4));
80 const bytes = new Uint8Array(header.length);
81 for (let index = 0; index < header.length; index += 1) bytes[index] = header.charCodeAt(index);
82 const dimensions = readImageDimensions(bytes);
83 if (dimensions === null || fitImageAttachmentSize(dimensions) === null) return null;
84 return await renderPhoto(image.uri, mimeType === "image/png" ? "png" : "jpeg");
85 } catch (error) {
86 console.warn("[composer-images] could not scale an oversized image", error);
87 return null;
88 }
89}