| 1 | // Client-side media processing — pure helpers over Files and canvases, no |
| 2 | // network and no keys. Images are downscaled + re-encoded before upload |
| 3 | // (which also strips EXIF/GPS) and get a BlurHash placeholder; videos give up |
| 4 | // a poster frame, dimensions, and duration. Everything here returns bytes for |
| 5 | // the caller to encrypt — the server only ever sees ciphertext. |
| 6 | import { encodeBlurhash } from "/static/blurhash.js"; |
| 7 | |
| 8 | const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| 9 | |
| 10 | // Pull a poster frame (JPEG), native dimensions, and duration out of a video |
| 11 | // File entirely client-side — the server only ever holds ciphertext, so it can't |
| 12 | // make a thumbnail. We draw the first decodable frame to a canvas and re-encode |
| 13 | // it small (which also strips any metadata). Resolves with a null posterBytes on |
| 14 | // failure (a codec the browser can't decode, a draw that throws) — the clip still |
| 15 | // uploads and plays wherever the format is supported. Never rejects. |
| 16 | export function videoMeta(file, maxPosterDim = 640) { |
| 17 | return new Promise((resolve) => { |
| 18 | const url = URL.createObjectURL(file); |
| 19 | const v = document.createElement("video"); |
| 20 | v.muted = true; |
| 21 | v.playsInline = true; |
| 22 | v.preload = "auto"; |
| 23 | // Safari won't decode/paint frames from a *detached* <video>, so a canvas draw |
| 24 | // would come out blank — keep it in the DOM but off-screen while we grab the |
| 25 | // frame, then remove it. |
| 26 | // Keep it in the DOM but off-screen at its NATURAL size while we grab a frame. |
| 27 | // Safari won't decode frames for a detached or effectively-invisible video (a |
| 28 | // 1px/opacity:0 element blanks the canvas), so only push it off-screen. |
| 29 | v.style.cssText = "position:fixed;left:-9999px;top:0;pointer-events:none"; |
| 30 | document.body.appendChild(v); |
| 31 | |
| 32 | let done = false; |
| 33 | let w = 0, h = 0, dur = 0; // captured at loadedmetadata, so dims survive a failed frame grab |
| 34 | const finish = (posterBytes) => { |
| 35 | if (done) return; |
| 36 | done = true; |
| 37 | clearTimeout(guard); |
| 38 | try { v.pause(); } catch { /* ignore */ } |
| 39 | URL.revokeObjectURL(url); |
| 40 | v.remove(); |
| 41 | resolve({ w, h, dur, posterBytes }); |
| 42 | }; |
| 43 | // Give up (still returning any dims we captured) if decoding stalls. |
| 44 | const guard = setTimeout(() => finish(null), 8000); |
| 45 | |
| 46 | const grab = async () => { |
| 47 | if (done || !w || !h) return; |
| 48 | let posterBytes = null; |
| 49 | try { |
| 50 | const scale = Math.min(maxPosterDim / w, maxPosterDim / h, 1); |
| 51 | const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale)); |
| 52 | const canvas = document.createElement("canvas"); |
| 53 | canvas.width = cw; |
| 54 | canvas.height = ch; |
| 55 | canvas.getContext("2d").drawImage(v, 0, 0, cw, ch); |
| 56 | const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.8)); |
| 57 | if (blob && blob.size > 0) posterBytes = new Uint8Array(await blob.arrayBuffer()); |
| 58 | } catch { /* undecodable frame → no poster (dims kept) */ } |
| 59 | finish(posterBytes); |
| 60 | }; |
| 61 | |
| 62 | v.onerror = () => finish(null); |
| 63 | v.onloadedmetadata = () => { |
| 64 | w = v.videoWidth || 0; |
| 65 | h = v.videoHeight || 0; |
| 66 | dur = v.duration || 0; |
| 67 | }; |
| 68 | // Playing muted forces Safari to actually decode a frame (a paused video |
| 69 | // often won't); grab once it's advancing, then stop. Seek fallback if play |
| 70 | // is blocked. |
| 71 | v.oncanplay = () => { |
| 72 | v.play().catch(() => { try { v.currentTime = Math.min(0.1, (dur || 0) / 2); } catch { /* ignore */ } }); |
| 73 | }; |
| 74 | v.onplaying = () => { setTimeout(grab, 150); }; |
| 75 | v.onseeked = grab; |
| 76 | v.src = url; |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | // Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS). |
| 81 | export async function processImage(file, maxDim = MAX_IMAGE_DIM) { |
| 82 | const bitmap = await createImageBitmap(file); |
| 83 | const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height)); |
| 84 | const w = Math.round(bitmap.width * scale); |
| 85 | const h = Math.round(bitmap.height * scale); |
| 86 | const canvas = document.createElement("canvas"); |
| 87 | canvas.width = w; |
| 88 | canvas.height = h; |
| 89 | canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h); |
| 90 | const out = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85)); |
| 91 | // The BlurHash placeholder, from a tiny second draw (DCT cost is O(pixels) — |
| 92 | // hash a ~32px thumbnail, never the full image). Rides INSIDE the encrypted |
| 93 | // payload (attachments[].bh) like w/h — it's derived from image content. |
| 94 | let bh = null; |
| 95 | try { |
| 96 | const ts = Math.min(1, 32 / Math.max(w, h)); |
| 97 | const tw = Math.max(1, Math.round(w * ts)); |
| 98 | const th = Math.max(1, Math.round(h * ts)); |
| 99 | const tiny = document.createElement("canvas"); |
| 100 | tiny.width = tw; |
| 101 | tiny.height = th; |
| 102 | tiny.getContext("2d").drawImage(bitmap, 0, 0, tw, th); |
| 103 | bh = encodeBlurhash(tiny.getContext("2d").getImageData(0, 0, tw, th).data, tw, th); |
| 104 | } catch { |
| 105 | // No hash is fine — receivers keep the flat placeholder. |
| 106 | } |
| 107 | return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h, bh }; |
| 108 | } |
| 109 | |