a tool for shared writing and social publishing
at feature/mention-services 138 lines 4.2 kB view raw
1import { Replicache } from "replicache"; 2import { ReplicacheMutators } from "../replicache"; 3import { supabaseBrowserClient } from "supabase/browserClient"; 4import type { FilterAttributes } from "src/replicache/attributes"; 5import { rgbaToDataURL, rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 6import { v7 } from "uuid"; 7 8export const localImages = new Map<string, boolean>(); 9export async function addImage( 10 file: File, 11 rep: Replicache<ReplicacheMutators>, 12 args: { 13 entityID: string; 14 attribute: keyof FilterAttributes<{ type: "image" }>; 15 }, 16) { 17 let client = supabaseBrowserClient(); 18 let cache = await caches.open("minilink-user-assets"); 19 let isAnimated = isAnimatedFormat(file.type); 20 let fileID = v7() + (isAnimated ? "." + file.name.split(".").pop() : ""); 21 let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID) 22 .data.publicUrl; 23 24 let uploadBlob: Blob; 25 let width: number; 26 let height: number; 27 if (isAnimated) { 28 // Skip re-encoding for animated formats (GIF, APNG, animated WebP) 29 // to preserve animation frames 30 uploadBlob = file; 31 let bitmap = await createImageBitmap(file); 32 width = bitmap.width; 33 height = bitmap.height; 34 bitmap.close(); 35 } else { 36 // Re-encode through canvas to bake EXIF orientation into pixel data. 37 // iPhone photos have EXIF rotation metadata that browsers respect, but 38 // Supabase's image transformation pipeline strips without applying. 39 let normalized = await normalizeOrientation(file); 40 uploadBlob = normalized.blob; 41 width = normalized.width; 42 height = normalized.height; 43 } 44 45 await cache.put( 46 new URL(url + "?local"), 47 new Response(uploadBlob, { 48 headers: { 49 "Content-Type": uploadBlob.type, 50 "Content-Length": uploadBlob.size.toString(), 51 }, 52 }), 53 ); 54 localImages.set(url, true); 55 56 let thumbhash = await getThumbHash(file); 57 if (navigator.serviceWorker) 58 await rep.mutate.assertFact({ 59 entity: args.entityID, 60 attribute: "block/image", 61 data: { 62 fallback: thumbhash, 63 type: "image", 64 local: rep.clientID, 65 src: url, 66 height, 67 width, 68 }, 69 }); 70 await client.storage.from("minilink-user-assets").upload(fileID, uploadBlob, { 71 cacheControl: "public, max-age=31560000, immutable", 72 }); 73 await rep.mutate.assertFact({ 74 entity: args.entityID, 75 attribute: args.attribute, 76 data: { 77 fallback: thumbhash, 78 type: "image", 79 src: url, 80 height, 81 width, 82 }, 83 }); 84} 85 86async function getThumbHash(file: File) { 87 const arrayBuffer = await file.arrayBuffer(); 88 const blob = new Blob([arrayBuffer], { type: file.type }); 89 const imageBitmap = await createImageBitmap(blob); 90 91 const canvas = document.createElement("canvas"); 92 const context = canvas.getContext("2d") as CanvasRenderingContext2D; 93 const maxDimension = 100; 94 let width = imageBitmap.width; 95 let height = imageBitmap.height; 96 97 if (width > height) { 98 if (width > maxDimension) { 99 height *= maxDimension / width; 100 width = maxDimension; 101 } 102 } else { 103 if (height > maxDimension) { 104 width *= maxDimension / height; 105 height = maxDimension; 106 } 107 } 108 109 canvas.width = width; 110 canvas.height = height; 111 context.drawImage(imageBitmap, 0, 0, width, height); 112 113 const imageData = context.getImageData(0, 0, width, height); 114 const thumbHash = thumbHashToDataURL( 115 rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 116 ); 117 return thumbHash; 118} 119 120function isAnimatedFormat(mimeType: string): boolean { 121 return mimeType === "image/gif" || mimeType === "image/apng"; 122} 123 124async function normalizeOrientation( 125 file: File, 126): Promise<{ blob: Blob; width: number; height: number }> { 127 let bitmap = await createImageBitmap(file); 128 let canvas = document.createElement("canvas"); 129 canvas.width = bitmap.width; 130 canvas.height = bitmap.height; 131 let ctx = canvas.getContext("2d")!; 132 ctx.drawImage(bitmap, 0, 0); 133 bitmap.close(); 134 let blob = await new Promise<Blob>((resolve) => 135 canvas.toBlob((b) => resolve(b!), "image/webp", 0.92), 136 ); 137 return { blob, width: canvas.width, height: canvas.height }; 138}