eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
6import { encodeBlurhash } from "/static/blurhash.js";
7
8const 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.
16export 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).
81export 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