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>
3 files changed,
+112
−102
CLAUDE.md+3 −0web/static/media.js+108 −0web/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). |