eleven / messenger Public

web: extract processImage + videoMeta into media.js (#933)

Verbatim move of the pure media helpers (downscale/re-encode/EXIF-strip
+ BlurHash; video poster/dims/duration) with the MAX_IMAGE_DIM cap and
the blurhash import they own. Bytes in, bytes out — no network, no keys.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 509cc13f6df95dc120c0dcc6e090c833bc32882a parent 7ef1c5a
3 files changed, +112 −102
  • CLAUDE.md +3 −0
  • web/static/media.js +108 −0
  • web/static/shell.js +1 −102
diff --git a/CLAUDE.md b/CLAUDE.md
index fc78aee..20afc30 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -180,6 +180,9 @@ Frontend module layout:
- `lightbox.js` — the shared photo lightbox (FLIP fly-in/out + gestures);
one copy, imported by both `shell.js` (avatar zoom) and `apps/chat.js`
(message photos) — they used to carry twin verbatim copies.
+- `media.js` — pure client-side media helpers: `processImage` (downscale +
+ re-encode + EXIF strip + BlurHash) and `videoMeta` (poster frame, dims,
+ duration). Bytes out; callers encrypt.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/media.js b/web/static/media.js
new file mode 100644
index 0000000..a9bb81f
--- /dev/null
+++ b/web/static/media.js
@@ -0,0 +1,108 @@
+// Client-side media processing — pure helpers over Files and canvases, no
+// network and no keys. Images are downscaled + re-encoded before upload
+// (which also strips EXIF/GPS) and get a BlurHash placeholder; videos give up
+// a poster frame, dimensions, and duration. Everything here returns bytes for
+// the caller to encrypt — the server only ever sees ciphertext.
+import { encodeBlurhash } from "/static/blurhash.js";
+
+const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
+
+// Pull a poster frame (JPEG), native dimensions, and duration out of a video
+// File entirely client-side — the server only ever holds ciphertext, so it can't
+// make a thumbnail. We draw the first decodable frame to a canvas and re-encode
+// it small (which also strips any metadata). Resolves with a null posterBytes on
+// failure (a codec the browser can't decode, a draw that throws) — the clip still
+// uploads and plays wherever the format is supported. Never rejects.
+export function videoMeta(file, maxPosterDim = 640) {
+ return new Promise((resolve) => {
+ const url = URL.createObjectURL(file);
+ const v = document.createElement("video");
+ v.muted = true;
+ v.playsInline = true;
+ v.preload = "auto";
+ // Safari won't decode/paint frames from a *detached* <video>, so a canvas draw
+ // would come out blank — keep it in the DOM but off-screen while we grab the
+ // frame, then remove it.
+ // Keep it in the DOM but off-screen at its NATURAL size while we grab a frame.
+ // Safari won't decode frames for a detached or effectively-invisible video (a
+ // 1px/opacity:0 element blanks the canvas), so only push it off-screen.
+ v.style.cssText = "position:fixed;left:-9999px;top:0;pointer-events:none";
+ document.body.appendChild(v);
+
+ let done = false;
+ let w = 0, h = 0, dur = 0; // captured at loadedmetadata, so dims survive a failed frame grab
+ const finish = (posterBytes) => {
+ if (done) return;
+ done = true;
+ clearTimeout(guard);
+ try { v.pause(); } catch { /* ignore */ }
+ URL.revokeObjectURL(url);
+ v.remove();
+ resolve({ w, h, dur, posterBytes });
+ };
+ // Give up (still returning any dims we captured) if decoding stalls.
+ const guard = setTimeout(() => finish(null), 8000);
+
+ const grab = async () => {
+ if (done || !w || !h) return;
+ let posterBytes = null;
+ try {
+ const scale = Math.min(maxPosterDim / w, maxPosterDim / h, 1);
+ const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
+ const canvas = document.createElement("canvas");
+ canvas.width = cw;
+ canvas.height = ch;
+ canvas.getContext("2d").drawImage(v, 0, 0, cw, ch);
+ const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.8));
+ if (blob && blob.size > 0) posterBytes = new Uint8Array(await blob.arrayBuffer());
+ } catch { /* undecodable frame → no poster (dims kept) */ }
+ finish(posterBytes);
+ };
+
+ v.onerror = () => finish(null);
+ v.onloadedmetadata = () => {
+ w = v.videoWidth || 0;
+ h = v.videoHeight || 0;
+ dur = v.duration || 0;
+ };
+ // Playing muted forces Safari to actually decode a frame (a paused video
+ // often won't); grab once it's advancing, then stop. Seek fallback if play
+ // is blocked.
+ v.oncanplay = () => {
+ v.play().catch(() => { try { v.currentTime = Math.min(0.1, (dur || 0) / 2); } catch { /* ignore */ } });
+ };
+ v.onplaying = () => { setTimeout(grab, 150); };
+ v.onseeked = grab;
+ v.src = url;
+ });
+}
+
+// Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS).
+export async function processImage(file, maxDim = MAX_IMAGE_DIM) {
+ const bitmap = await createImageBitmap(file);
+ const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
+ const w = Math.round(bitmap.width * scale);
+ const h = Math.round(bitmap.height * scale);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
+ const out = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
+ // The BlurHash placeholder, from a tiny second draw (DCT cost is O(pixels) —
+ // hash a ~32px thumbnail, never the full image). Rides INSIDE the encrypted
+ // payload (attachments[].bh) like w/h — it's derived from image content.
+ let bh = null;
+ try {
+ const ts = Math.min(1, 32 / Math.max(w, h));
+ const tw = Math.max(1, Math.round(w * ts));
+ const th = Math.max(1, Math.round(h * ts));
+ const tiny = document.createElement("canvas");
+ tiny.width = tw;
+ tiny.height = th;
+ tiny.getContext("2d").drawImage(bitmap, 0, 0, tw, th);
+ bh = encodeBlurhash(tiny.getContext("2d").getImageData(0, 0, tw, th).data, tw, th);
+ } catch {
+ // No hash is fine — receivers keep the flat placeholder.
+ }
+ return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h, bh };
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 5d5ccc0..353b23e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -50,7 +50,7 @@ import {
import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { startBootScene, finishBootScene } from "/static/bootscene.js";
-import { encodeBlurhash } from "/static/blurhash.js";
+import { processImage, videoMeta } from "/static/media.js";
import { createIntegrity } from "/static/integrity.js";
import {
registerFloorCommand,
@@ -184,7 +184,6 @@ const MAX_UPLOAD = 25 * 1024 * 1024; // matches the default upload cap (main.go)
// server's default LCHAT_MAX_UPLOAD_BYTES; a lower per-instance cap just means
// the server rejects the upload and the composer shows a "couldn't add" message.
const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024;
-const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
// ---- split view divider ----------------------------------------------------
// Desktop-only Chat|Interface split (see the [data-app-view="split"] CSS):
@@ -7198,106 +7197,6 @@ function deleteMessage(msgId) {
// ---- attachments (images + files) ---------------------------------------
-// Pull a poster frame (JPEG), native dimensions, and duration out of a video
-// File entirely client-side — the server only ever holds ciphertext, so it can't
-// make a thumbnail. We draw the first decodable frame to a canvas and re-encode
-// it small (which also strips any metadata). Resolves with a null posterBytes on
-// failure (a codec the browser can't decode, a draw that throws) — the clip still
-// uploads and plays wherever the format is supported. Never rejects.
-function videoMeta(file, maxPosterDim = 640) {
- return new Promise((resolve) => {
- const url = URL.createObjectURL(file);
- const v = document.createElement("video");
- v.muted = true;
- v.playsInline = true;
- v.preload = "auto";
- // Safari won't decode/paint frames from a *detached* <video>, so a canvas draw
- // would come out blank — keep it in the DOM but off-screen while we grab the
- // frame, then remove it.
- // Keep it in the DOM but off-screen at its NATURAL size while we grab a frame.
- // Safari won't decode frames for a detached or effectively-invisible video (a
- // 1px/opacity:0 element blanks the canvas), so only push it off-screen.
- v.style.cssText = "position:fixed;left:-9999px;top:0;pointer-events:none";
- document.body.appendChild(v);
-
- let done = false;
- let w = 0, h = 0, dur = 0; // captured at loadedmetadata, so dims survive a failed frame grab
- const finish = (posterBytes) => {
- if (done) return;
- done = true;
- clearTimeout(guard);
- try { v.pause(); } catch { /* ignore */ }
- URL.revokeObjectURL(url);
- v.remove();
- resolve({ w, h, dur, posterBytes });
- };
- // Give up (still returning any dims we captured) if decoding stalls.
- const guard = setTimeout(() => finish(null), 8000);
-
- const grab = async () => {
- if (done || !w || !h) return;
- let posterBytes = null;
- try {
- const scale = Math.min(maxPosterDim / w, maxPosterDim / h, 1);
- const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
- const canvas = document.createElement("canvas");
- canvas.width = cw;
- canvas.height = ch;
- canvas.getContext("2d").drawImage(v, 0, 0, cw, ch);
- const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.8));
- if (blob && blob.size > 0) posterBytes = new Uint8Array(await blob.arrayBuffer());
- } catch { /* undecodable frame → no poster (dims kept) */ }
- finish(posterBytes);
- };
-
- v.onerror = () => finish(null);
- v.onloadedmetadata = () => {
- w = v.videoWidth || 0;
- h = v.videoHeight || 0;
- dur = v.duration || 0;
- };
- // Playing muted forces Safari to actually decode a frame (a paused video
- // often won't); grab once it's advancing, then stop. Seek fallback if play
- // is blocked.
- v.oncanplay = () => {
- v.play().catch(() => { try { v.currentTime = Math.min(0.1, (dur || 0) / 2); } catch { /* ignore */ } });
- };
- v.onplaying = () => { setTimeout(grab, 150); };
- v.onseeked = grab;
- v.src = url;
- });
-}
-
-// Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS).
-async function processImage(file, maxDim = MAX_IMAGE_DIM) {
- const bitmap = await createImageBitmap(file);
- const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
- const w = Math.round(bitmap.width * scale);
- const h = Math.round(bitmap.height * scale);
- const canvas = document.createElement("canvas");
- canvas.width = w;
- canvas.height = h;
- canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
- const out = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
- // The BlurHash placeholder, from a tiny second draw (DCT cost is O(pixels) —
- // hash a ~32px thumbnail, never the full image). Rides INSIDE the encrypted
- // payload (attachments[].bh) like w/h — it's derived from image content.
- let bh = null;
- try {
- const ts = Math.min(1, 32 / Math.max(w, h));
- const tw = Math.max(1, Math.round(w * ts));
- const th = Math.max(1, Math.round(h * ts));
- const tiny = document.createElement("canvas");
- tiny.width = tw;
- tiny.height = th;
- tiny.getContext("2d").drawImage(bitmap, 0, 0, tw, th);
- bh = encodeBlurhash(tiny.getContext("2d").getImageData(0, 0, tw, th).data, tw, th);
- } catch {
- // No hash is fine — receivers keep the flat placeholder.
- }
- return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h, bh };
-}
-
// Fetch + decrypt a blob to raw bytes (no object URL) — for consumers that
// need the plaintext itself (the setup flow re-posting a sent photo as the
// branding image; fetch(blob:) is blocked by the page CSP's connect-src).