1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322 | /**
* Shared media encoding (M3 chat; reused by M5 posts).
*
* Images: canvas re-encode — decoding + redrawing strips EXIF/GPS and every
* other metadata chunk STRUCTURALLY (only pixels survive the canvas). Two
* outputs per image: a thumbnail (max edge 256, target ≤40 KB) rendered
* inline, and a display version (max edge 1080, target ≤300 KB) for the
* full-screen viewer. WebP preferred; JPEG fallback where canvas won't emit
* WebP (Safari returns PNG for unsupported types — detected and rejected).
* Undecodable input (e.g. HEIC on non-Safari) throws MediaDecodeError — the
* UI shows an honest error instead of a broken upload.
*
* Voice: MediaRecorder, mime preference audio/webm;codecs=opus →
* audio/webm → audio/mp4 (iOS Safari AAC) → browser default; 60 s hard cap.
* One blob out; storage/encryption ride the same lib RoomLog blob path as
* images (putImage seals arbitrary bytes).
*/
import type { Bytes } from "../lib/crypto";
export interface EncodedImage {
thumb: Bytes;
display: Bytes;
w: number; // display pixel size (thumb keeps the same aspect)
h: number;
mime: string; // of BOTH outputs ("image/webp" | "image/jpeg")
}
export interface VoiceClip {
bytes: Bytes;
dur: number; // whole seconds ≥ 1
mime: string;
}
const DISPLAY_EDGE = 1080;
const THUMB_EDGE = 256;
const DISPLAY_TARGET = 300 * 1024;
const THUMB_TARGET = 40 * 1024;
const DISPLAY_HARD = 700 * 1024; // over target but under the lib's 2 MiB cap
const THUMB_HARD = 120 * 1024;
export const VOICE_MAX_SEC = 60;
const VOICE_HARD_BYTES = 1536 * 1024; // 60 s opus ≈ 250 KB; AAC ≈ 750 KB
/** Decode-bomb bound (shared by chat images and public avatars): a claimed
* w/h only sizes the LAYOUT box — a tiny low-complexity blob can decode to
* hundreds of MB of RGBA. Any received image whose actual decoded bitmap
* exceeds this pixel count is refused before a URL reaches an <img>. */
export const IMG_DECODE_MAX_PX = 4096 * 4096;
/** True iff the blob decodes to a sane bitmap (≤ IMG_DECODE_MAX_PX pixels).
* The probe bitmap is closed immediately — nothing oversized is retained. */
export async function decodeWithinBounds(blob: Blob): Promise<boolean> {
try {
const bmp = await createImageBitmap(blob);
const px = bmp.width * bmp.height;
bmp.close();
return px > 0 && px <= IMG_DECODE_MAX_PX;
} catch {
return false; // undecodable bytes — refuse just the same
}
}
/** The browser can't decode this file (HEIC outside Safari, corrupt data…). */
export class MediaDecodeError extends Error {
constructor() {
super("undecodable image");
this.name = "MediaDecodeError";
}
}
async function decodeBitmap(file: Blob): Promise<ImageBitmap> {
try {
// from-image: bake the EXIF orientation into the pixels BEFORE the tag is
// stripped, so re-encoded photos don't come out sideways
return await createImageBitmap(file, { imageOrientation: "from-image" });
} catch {
// some browsers can't createImageBitmap certain formats — go through <img>
}
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.src = url;
await img.decode();
return await createImageBitmap(img);
} catch {
throw new MediaDecodeError();
} finally {
URL.revokeObjectURL(url);
}
}
function drawScaled(bitmap: ImageBitmap, maxEdge: number): HTMLCanvasElement {
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return canvas;
}
const toBlob = (canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> =>
new Promise((r) => canvas.toBlob(r, type, quality));
/**
* Walk a (type, quality) ladder; first candidate under `target` wins, else
* the smallest one under `hard`. Candidates whose type differs from the
* requested one are discarded (Safari silently substitutes PNG for WebP).
*/
async function encodeLadder(
canvas: HTMLCanvasElement,
ladder: readonly (readonly [string, number])[],
target: number,
hard: number,
): Promise<Blob> {
let best: Blob | null = null;
for (const [type, quality] of ladder) {
const blob = await toBlob(canvas, type, quality);
if (!blob || blob.type !== type) continue;
if (blob.size <= target) return blob;
if (blob.size <= hard && (!best || blob.size < best.size)) best = blob;
}
if (best) return best;
throw new Error("image too large after compression");
}
const bytesOf = async (b: Blob): Promise<Bytes> => new Uint8Array(await b.arrayBuffer());
/**
* Re-encode one user image into the thumb + display pair. Throws
* MediaDecodeError when the browser can't decode the input at all.
*/
export async function encodeImage(file: Blob): Promise<EncodedImage> {
const bitmap = await decodeBitmap(file);
try {
const displayCanvas = drawScaled(bitmap, DISPLAY_EDGE);
const display = await encodeLadder(
displayCanvas,
[
["image/webp", 0.82],
["image/webp", 0.6],
["image/jpeg", 0.82],
["image/jpeg", 0.6],
],
DISPLAY_TARGET,
DISPLAY_HARD,
);
const thumbCanvas = drawScaled(bitmap, THUMB_EDGE);
// same format family as the display pick, quality ladder of its own
const thumb = await encodeLadder(
thumbCanvas,
[
[display.type, 0.7],
[display.type, 0.5],
],
THUMB_TARGET,
THUMB_HARD,
);
return {
thumb: await bytesOf(thumb),
display: await bytesOf(display),
w: displayCanvas.width,
h: displayCanvas.height,
mime: display.type,
};
} finally {
bitmap.close();
}
}
// ---- avatar (M4 public profiles) --------------------------------------------
export interface EncodedAvatar {
bytes: Bytes;
w: number; // == h (square crop)
h: number;
mime: string; // "image/webp" | "image/jpeg"
}
const AVATAR_EDGE = 512;
const AVATAR_TARGET = 96 * 1024;
/** §3 profile.avatar.bytes hard cap (128 KiB) — receivers clamp to this too. */
export const AVATAR_HARD = 131_072;
/**
* Re-encode one user image into a square public avatar: center square crop,
* max edge 512, same canvas discipline as encodeImage (EXIF/GPS stripped
* structurally), webp preferred / jpeg fallback, ≤128 KiB hard (§3 cap).
* Throws MediaDecodeError on undecodable input.
*/
export async function encodeAvatar(file: Blob): Promise<EncodedAvatar> {
const bitmap = await decodeBitmap(file);
try {
const crop = Math.min(bitmap.width, bitmap.height);
const edge = Math.min(AVATAR_EDGE, crop);
const canvas = document.createElement("canvas");
canvas.width = edge;
canvas.height = edge;
canvas
.getContext("2d")!
.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, edge, edge);
const blob = await encodeLadder(
canvas,
[
["image/webp", 0.85],
["image/webp", 0.65],
["image/webp", 0.45],
["image/jpeg", 0.85],
["image/jpeg", 0.6],
],
AVATAR_TARGET,
AVATAR_HARD,
);
return { bytes: await bytesOf(blob), w: edge, h: edge, mime: blob.type };
} finally {
bitmap.close();
}
}
// ---- voice ------------------------------------------------------------------
/** Preferred recorder mime; "" = let the browser pick; null = no recorder. */
export function pickAudioMime(): string | null {
const MR = window.MediaRecorder as typeof MediaRecorder | undefined;
if (!MR) return null;
if (typeof MR.isTypeSupported !== "function") return "";
for (const m of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]) {
if (MR.isTypeSupported(m)) return m;
}
return "";
}
/**
* One-shot voice recording. start() asks for the mic; stop() resolves the
* pending clip; cancel() discards it. The 60 s hard cap auto-stops. onTick
* fires every ~250 ms with elapsed whole seconds for the countdown UI.
*/
export class VoiceRecorder {
#rec: MediaRecorder | null = null;
#stream: MediaStream | null = null;
#chunks: BlobPart[] = [];
#startedAt = 0;
#timer: ReturnType<typeof setInterval> | null = null;
#cancelled = false;
#settled = false;
onTick: (elapsedSec: number) => void = () => {};
/** null ⇒ cancelled or empty recording. */
onDone: (clip: VoiceClip | null) => void = () => {};
get recording(): boolean {
return this.#rec !== null && this.#rec.state === "recording";
}
/** Throws when the recorder is unsupported or the mic is denied. */
async start(): Promise<void> {
const mime = pickAudioMime();
if (mime === null) throw new Error("MediaRecorder unsupported");
this.#stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const rec = new MediaRecorder(this.#stream, mime ? { mimeType: mime } : undefined);
this.#rec = rec;
rec.ondataavailable = (e: BlobEvent) => {
if (e.data && e.data.size > 0) this.#chunks.push(e.data);
};
rec.onstop = () => this.#finish(rec.mimeType || mime || "audio/webm");
rec.start();
this.#startedAt = Date.now();
this.#timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.#startedAt) / 1000);
try {
this.onTick(elapsed);
} catch {}
if (elapsed >= VOICE_MAX_SEC) this.stop(); // hard cap ⇒ stop & deliver
}, 250);
}
stop(): void {
this.#teardownTimer();
const rec = this.#rec;
if (rec && rec.state !== "inactive") {
try {
rec.stop(); // onstop → #finish
return;
} catch {}
}
this.#finish("audio/webm");
}
cancel(): void {
this.#cancelled = true;
this.stop();
}
#teardownTimer(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
#finish(mime: string): void {
if (this.#settled) return;
this.#settled = true;
this.#teardownTimer();
for (const t of this.#stream?.getTracks() ?? []) {
try {
t.stop();
} catch {}
}
const deliver = async (): Promise<VoiceClip | null> => {
if (this.#cancelled || this.#chunks.length === 0) return null;
const blob = new Blob(this.#chunks, { type: mime });
const bytes = new Uint8Array(await blob.arrayBuffer());
if (bytes.length === 0 || bytes.length > VOICE_HARD_BYTES) return null;
const dur = Math.min(VOICE_MAX_SEC, Math.max(1, Math.round((Date.now() - this.#startedAt) / 1000)));
return { bytes, dur, mime: mime.split(";")[0] || "audio/webm" };
};
void deliver().then(
(clip) => this.onDone(clip),
() => this.onDone(null),
);
}
}
|