|
| 1 | +import { sha256 } from '@noble/hashes/sha2'; |
| 2 | +import { v2, type PolycentricClient } from '@polycentric/react-native'; |
| 3 | + |
| 4 | +/** Default variant edge lengths. */ |
| 5 | +export const DEFAULT_IMAGE_VARIANT_SIZES = [48, 128, 512]; |
| 6 | + |
| 7 | +export type ProcessAndUploadOptions = { |
| 8 | + /** Variant edge lengths to emit. Interpreted per `mode`. */ |
| 9 | + sizes?: number[]; |
| 10 | + /** `fill` crops to a square (default, for avatars). `fit` preserves aspect. */ |
| 11 | + mode?: 'fill' | 'fit'; |
| 12 | + /** Source image width. Required for `mode: 'fit'` so variants record correct dims. */ |
| 13 | + sourceWidth?: number; |
| 14 | + /** Source image height. Required for `mode: 'fit'` so variants record correct dims. */ |
| 15 | + sourceHeight?: number; |
| 16 | +}; |
| 17 | + |
| 18 | +/** |
| 19 | + * Fetch an image from `uri`, resize it into each size in `sizes` via |
| 20 | + * the core's JPEG encoder, upload each variant's bytes to the client's |
| 21 | + * servers, and return the assembled `ImageSet`. |
| 22 | + */ |
| 23 | +export async function processAndUploadImage( |
| 24 | + client: PolycentricClient, |
| 25 | + uri: string, |
| 26 | + options: ProcessAndUploadOptions = {}, |
| 27 | +): Promise<v2.ImageSet> { |
| 28 | + const sizes = options.sizes ?? DEFAULT_IMAGE_VARIANT_SIZES; |
| 29 | + const mode = options.mode ?? 'fill'; |
| 30 | + |
| 31 | + const response = await fetch(uri); |
| 32 | + const raw = new Uint8Array(await response.arrayBuffer()); |
| 33 | + |
| 34 | + const variants = await Promise.all( |
| 35 | + sizes.map(async (size) => { |
| 36 | + const jpeg = client.processImageToJpeg(raw, size, size, mode); |
| 37 | + const { width, height } = computeOutputDims( |
| 38 | + size, |
| 39 | + size, |
| 40 | + mode, |
| 41 | + options.sourceWidth, |
| 42 | + options.sourceHeight, |
| 43 | + ); |
| 44 | + const image = v2.Image.create({ |
| 45 | + blob: { |
| 46 | + digest: { |
| 47 | + type: v2.ContentDigestType.SHA256, |
| 48 | + value: sha256(jpeg), |
| 49 | + }, |
| 50 | + mimeType: 'image/jpeg', |
| 51 | + size: BigInt(jpeg.length), |
| 52 | + }, |
| 53 | + width, |
| 54 | + height, |
| 55 | + }); |
| 56 | + return { image, body: jpeg }; |
| 57 | + }), |
| 58 | + ); |
| 59 | + |
| 60 | + await Promise.all( |
| 61 | + variants.map((v) => |
| 62 | + v.image.blob |
| 63 | + ? client.uploadBlob(v.image.blob, v.body) |
| 64 | + : Promise.resolve(), |
| 65 | + ), |
| 66 | + ); |
| 67 | + |
| 68 | + return v2.ImageSet.create({ images: variants.map((v) => v.image) }); |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Match the `image` crate's `resize()`: scale both axes by |
| 73 | + * `min(targetW/srcW, targetH/srcH)` and round. For fill mode the |
| 74 | + * output is always exactly the requested bounds. For fit mode without |
| 75 | + * known source dims, we fall back to the bounds (the client can re- |
| 76 | + * derive aspect from the served image if needed). |
| 77 | + */ |
| 78 | +function computeOutputDims( |
| 79 | + targetW: number, |
| 80 | + targetH: number, |
| 81 | + mode: 'fill' | 'fit', |
| 82 | + srcW?: number, |
| 83 | + srcH?: number, |
| 84 | +): { width: number; height: number } { |
| 85 | + if (mode === 'fill' || !srcW || !srcH) { |
| 86 | + return { width: targetW, height: targetH }; |
| 87 | + } |
| 88 | + const ratio = Math.min(targetW / srcW, targetH / srcH); |
| 89 | + return { |
| 90 | + width: Math.max(1, Math.round(srcW * ratio)), |
| 91 | + height: Math.max(1, Math.round(srcH * ratio)), |
| 92 | + }; |
| 93 | +} |
0 commit comments