@sphoro/bugcatch-web-sdk 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@sphoro/bugcatch-web-sdk",
3
+ "version": "0.13.1",
4
+ "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
+ "type": "module",
6
+ "main": "dist/bugcatch.es.js",
7
+ "module": "dist/bugcatch.es.js",
8
+ "browser": "dist/bugcatch.umd.js",
9
+ "unpkg": "dist/bugcatch.umd.js",
10
+ "jsdelivr": "dist/bugcatch.umd.js",
11
+ "types": "src/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.d.ts",
15
+ "import": "./dist/bugcatch.es.js",
16
+ "default": "./dist/bugcatch.es.js"
17
+ },
18
+ "./dist/bugcatch.umd.js": "./dist/bugcatch.umd.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "sideEffects": false,
26
+ "scripts": {
27
+ "build": "vite build",
28
+ "dev": "vite build --watch",
29
+ "prepublishOnly": "vite build"
30
+ },
31
+ "keywords": [
32
+ "bug-report",
33
+ "bug-tracking",
34
+ "error-reporting",
35
+ "console-capture",
36
+ "network-capture",
37
+ "feedback-widget",
38
+ "browser",
39
+ "sdk"
40
+ ],
41
+ "license": "MIT",
42
+ "homepage": "https://github.com/floki5G/sphoro.bugcatch/tree/main/bugcatch-sdk#readme",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/floki5G/sphoro.bugcatch.git",
46
+ "directory": "bugcatch-sdk"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/floki5G/sphoro.bugcatch/issues"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "devDependencies": {
55
+ "vite": "^5.4.0"
56
+ }
57
+ }
package/src/audio.js ADDED
@@ -0,0 +1,191 @@
1
+ // Microphone to µ-law, and back.
2
+ //
3
+ // sphoro.voice speaks G.711 µ-law at 8 kHz in both directions — the format a
4
+ // telephone carrier hands it, which is what makes a browser call and a phone
5
+ // call indistinguishable to everything downstream. A browser has neither: it
6
+ // captures 32-bit floats at whatever rate the sound card runs at. The gap
7
+ // between those two is a codec and a resampler, and neither is available from
8
+ // the platform, so both are here.
9
+ //
10
+ // This file exists rather than the two callers each having a copy. The agent
11
+ // call sends µ-law because that is the whole conversation; a live call sends it
12
+ // because sphoro.voice cannot record or transcribe a conversation it is not in
13
+ // the middle of, and asks each browser for a copy of its own microphone. Two
14
+ // implementations of G.711 in one package would be one implementation and one
15
+ // that is subtly wrong on the day somebody fixes only the first.
16
+ //
17
+ // Nothing is imported to do it. The SDK runs on someone else's page and has no
18
+ // dependencies, and that rule does not bend for audio.
19
+
20
+ /** The wire's sample rate. Not negotiable — see the note above. */
21
+ export const WIRE_RATE = 8000;
22
+
23
+ /**
24
+ * An audio context, at the wire's rate where the browser will give us one.
25
+ *
26
+ * Asking for 8 kHz outright makes the resampler below dead code on most
27
+ * machines. Some browsers refuse a rate their hardware does not like, so the
28
+ * fallback is an ordinary context and the manual path.
29
+ */
30
+ export function createContext(rate = WIRE_RATE) {
31
+ const Ctor = window.AudioContext || window.webkitAudioContext;
32
+ try {
33
+ return new Ctor({ sampleRate: rate });
34
+ } catch {
35
+ return new Ctor();
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Start a context that the browser has parked, if it will let us.
41
+ *
42
+ * Resolves either way. There is nothing useful to do with the rejection: it
43
+ * means this browser wants a user gesture we are not inside, and the call is
44
+ * still worth attempting — a context that stays suspended is a silent call,
45
+ * which is what the caller finds out about from the states it is already being
46
+ * sent.
47
+ */
48
+ export async function resumeContext(context) {
49
+ if (context.state !== 'suspended') return;
50
+ try {
51
+ await context.resume();
52
+ } catch { /* no activation to spend */ }
53
+ }
54
+
55
+ /**
56
+ * Drop a signal to the wire rate by averaging each source window.
57
+ *
58
+ * Exported for its own test: the browsers that refuse an 8 kHz context are the
59
+ * ones that need this, and they are exactly the browsers a developer is least
60
+ * likely to be looking at when they change it.
61
+ *
62
+ * Averaging rather than picking every nth sample: plain decimation aliases
63
+ * everything above 4 kHz down into the speech band, and consonants are exactly
64
+ * what lives up there. This is a crude low-pass, but it is the difference
65
+ * between "sixty" and "sixteen" surviving the trip.
66
+ */
67
+ export function downsample(input, fromRate, toRate) {
68
+ const ratio = fromRate / toRate;
69
+ const out = new Float32Array(Math.floor(input.length / ratio));
70
+ for (let i = 0; i < out.length; i++) {
71
+ const start = Math.floor(i * ratio);
72
+ const end = Math.min(Math.floor((i + 1) * ratio), input.length);
73
+ let sum = 0;
74
+ for (let j = start; j < end; j++) sum += input[j];
75
+ out[i] = end > start ? sum / (end - start) : 0;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** G.711 µ-law: the bias and clip the standard specifies. */
81
+ const MU_BIAS = 0x84;
82
+ const MU_CLIP = 32635;
83
+
84
+ /**
85
+ * Float samples to µ-law bytes, ready to put on the wire.
86
+ *
87
+ * Exported for the codec test rather than for callers: this file is not the
88
+ * package's entry point, and a hand-rolled G.711 that nothing checks is a
89
+ * silent call nobody can debug from the outside.
90
+ */
91
+ export function encodeMuLaw(samples) {
92
+ const out = new Uint8Array(samples.length);
93
+ for (let i = 0; i < samples.length; i++) {
94
+ // Clamped before scaling: a float outside [-1, 1] is legal in Web Audio and
95
+ // would wrap round to the opposite sign as an integer.
96
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
97
+ let sample = Math.round(clamped * 32767);
98
+
99
+ const sign = sample < 0 ? 0x80 : 0;
100
+ if (sign) sample = -sample;
101
+ if (sample > MU_CLIP) sample = MU_CLIP;
102
+ sample += MU_BIAS;
103
+
104
+ let exponent = 7;
105
+ for (let mask = 0x4000; (sample & mask) === 0 && exponent > 0; exponent--, mask >>= 1);
106
+ const mantissa = (sample >> (exponent + 3)) & 0x0F;
107
+ out[i] = ~(sign | (exponent << 4) | mantissa) & 0xFF;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** µ-law bytes back to float samples, ready for an AudioBuffer. */
113
+ export function decodeMuLaw(bytes) {
114
+ const out = new Float32Array(bytes.length);
115
+ for (let i = 0; i < bytes.length; i++) {
116
+ const byte = ~bytes[i] & 0xFF;
117
+ const sign = byte & 0x80;
118
+ const exponent = (byte >> 4) & 0x07;
119
+ const mantissa = byte & 0x0F;
120
+ const magnitude = (((mantissa << 3) + MU_BIAS) << exponent) - MU_BIAS;
121
+ out[i] = (sign ? -magnitude : magnitude) / 32768;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * How much audio rides in one frame.
128
+ *
129
+ * Measured in samples, and the context above runs at 8 kHz wherever the browser
130
+ * allows it — so this is 64 ms of speech, not the 20-odd it would be at
131
+ * hardware rate. That distinction is the whole reason for the number: at 2048
132
+ * the same code sends a quarter of a second per frame, which is a quarter of a
133
+ * second added to the start of every turn and to every barge-in, and it sounds
134
+ * exactly like an agent that is slow to notice you talking.
135
+ *
136
+ * Not smaller than this. ScriptProcessorNode runs on the main thread, and a
137
+ * block it cannot fill in time is a block it drops — which is a gap in the
138
+ * middle of a word rather than a delay before one. Powers of two only.
139
+ */
140
+ export const CAPTURE_BLOCK = 512;
141
+
142
+ /**
143
+ * Tap a microphone and hand each block over as µ-law bytes.
144
+ *
145
+ * ScriptProcessorNode is deprecated in favour of AudioWorklet, and is used
146
+ * anyway. A worklet's processor has to be loaded from a URL, which on this SDK
147
+ * means a blob: URL — and a blob: worklet is refused outright by the
148
+ * Content-Security-Policy of a great many of the sites this code runs on. A
149
+ * deprecated node that works on a customer's page beats a modern one that is
150
+ * blocked by their headers.
151
+ *
152
+ * Returns a `stop` that lets go of the whole chain. The microphone track is
153
+ * deliberately *not* stopped by it: on a live call the same track is carrying
154
+ * the conversation, and stopping it to end a recording would end the call.
155
+ *
156
+ * @param {AudioContext} context
157
+ * @param {MediaStream} stream
158
+ * @param {(bytes: Uint8Array) => void} onFrame
159
+ * @param {number} [wireRate]
160
+ * @returns {{stop: () => void}}
161
+ */
162
+ export function tapMicrophone(context, stream, onFrame, wireRate = WIRE_RATE) {
163
+ const source = context.createMediaStreamSource(stream);
164
+ const capture = context.createScriptProcessor(CAPTURE_BLOCK, 1, 1);
165
+
166
+ capture.onaudioprocess = (event) => {
167
+ const input = event.inputBuffer.getChannelData(0);
168
+ const frames = context.sampleRate === wireRate
169
+ ? input
170
+ : downsample(input, context.sampleRate, wireRate);
171
+ onFrame(encodeMuLaw(frames));
172
+ };
173
+
174
+ source.connect(capture);
175
+ // A ScriptProcessorNode does not run unless it is connected to a destination,
176
+ // even when nothing is meant to come out of it. Zero gain is what keeps the
177
+ // speaker from hearing their own voice played back at them.
178
+ const mute = context.createGain();
179
+ mute.gain.value = 0;
180
+ capture.connect(mute);
181
+ mute.connect(context.destination);
182
+
183
+ return {
184
+ stop() {
185
+ capture.onaudioprocess = null;
186
+ try { source.disconnect(); } catch { /* already gone */ }
187
+ try { capture.disconnect(); } catch { /* already gone */ }
188
+ try { mute.disconnect(); } catch { /* already gone */ }
189
+ },
190
+ };
191
+ }
package/src/capture.js ADDED
@@ -0,0 +1,300 @@
1
+ // Screenshots, screen recordings and file attachments.
2
+ //
3
+ // Capture always goes through getDisplayMedia, so the browser shows its own
4
+ // picker and permission prompt — nothing leaves the page without the person
5
+ // explicitly choosing what to share. There is no DOM-rasterising fallback on
6
+ // purpose: it would need a heavy dependency and would still silently miss
7
+ // cross-origin iframes, canvas and video, which is worse than not offering it.
8
+
9
+ /**
10
+ * Pristine references, taken at module-evaluation time — which happens on
11
+ * import, before init() patches anything.
12
+ *
13
+ * Uploads must not be recorded by our own network capture: a report would
14
+ * otherwise describe its own attachment upload, and a retry would append to a
15
+ * buffer that is already being serialised.
16
+ */
17
+ const nativeFetch = typeof window !== 'undefined' && window.fetch
18
+ ? window.fetch.bind(window)
19
+ : null;
20
+ const nativeXhrOpen = typeof XMLHttpRequest !== 'undefined'
21
+ ? XMLHttpRequest.prototype.open
22
+ : null;
23
+ const nativeXhrSend = typeof XMLHttpRequest !== 'undefined'
24
+ ? XMLHttpRequest.prototype.send
25
+ : null;
26
+
27
+ /**
28
+ * What the API will presign. Anything else is rejected before we ask.
29
+ *
30
+ * Mirrors UPLOAD_MIMES in the API's storage.service.ts. The two are checked
31
+ * independently — this one to fail early with a message the person can act on,
32
+ * that one because a browser is not a place to enforce anything.
33
+ */
34
+ export const UPLOAD_MIMES = [
35
+ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'video/webm', 'video/mp4',
36
+ 'audio/webm', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/wav',
37
+ 'application/pdf', 'text/plain', 'text/csv', 'application/json', 'application/zip',
38
+ 'application/msword',
39
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
40
+ 'application/vnd.ms-excel',
41
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
42
+ ];
43
+
44
+ /**
45
+ * What a *bug report* may carry, which is narrower than what may be uploaded.
46
+ *
47
+ * A report's attachments are evidence of the bug — what the screen looked like,
48
+ * what the session did — and the API types them SCREENSHOT or VIDEO. A document
49
+ * has no honest value there: filed as a SCREENSHOT it would render as a broken
50
+ * image in the dashboard's media viewer.
51
+ *
52
+ * Documents and voice notes belong on a *message*, where the type is derived
53
+ * from the mime and the reader gets a player or a download.
54
+ */
55
+ export const REPORT_MIMES = [
56
+ 'image/png', 'image/jpeg', 'image/webp', 'video/webm', 'video/mp4',
57
+ ];
58
+
59
+ export const voiceSupported = () =>
60
+ Boolean(navigator?.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined');
61
+
62
+ export const captureSupported = () =>
63
+ Boolean(navigator?.mediaDevices?.getDisplayMedia && typeof MediaRecorder !== 'undefined');
64
+
65
+ export const screenshotSupported = () =>
66
+ Boolean(navigator?.mediaDevices?.getDisplayMedia);
67
+
68
+ /**
69
+ * MediaRecorder reports `video/webm;codecs=vp9`, but the presign signature
70
+ * binds an exact Content-Type and the API only allows the base types. Strip
71
+ * the codec parameter so the type we sign, the header we send and the type the
72
+ * API stored all agree.
73
+ */
74
+ export function normalizeMime(mime, allowed = UPLOAD_MIMES) {
75
+ const base = String(mime || '').split(';')[0].trim().toLowerCase();
76
+ return allowed.includes(base) ? base : null;
77
+ }
78
+
79
+ /** Grab a single frame of a chosen screen/window/tab as a PNG blob. */
80
+ export async function captureScreenshot() {
81
+ const stream = await navigator.mediaDevices.getDisplayMedia({
82
+ video: { frameRate: 1 },
83
+ audio: false,
84
+ });
85
+ try {
86
+ const track = stream.getVideoTracks()[0];
87
+ const video = document.createElement('video');
88
+ video.srcObject = stream;
89
+ video.muted = true;
90
+ video.playsInline = true;
91
+ await video.play();
92
+
93
+ // Wait one frame so the first paint isn't blank.
94
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
95
+
96
+ const { width, height } = track.getSettings();
97
+ const canvas = document.createElement('canvas');
98
+ canvas.width = width || video.videoWidth;
99
+ canvas.height = height || video.videoHeight;
100
+ canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
101
+
102
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
103
+ if (!blob) throw new Error('The browser returned an empty screenshot.');
104
+ return { blob, mime: 'image/png', kind: 'SCREENSHOT' };
105
+ } finally {
106
+ // Always release the capture, including on failure — otherwise the browser
107
+ // keeps showing "sharing your screen".
108
+ stream.getTracks().forEach((t) => t.stop());
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Start recording a chosen screen/window/tab. Returns a handle with stop() and
114
+ * cancel(); the caller decides which. Recording ends on its own if the user
115
+ * clicks the browser's "Stop sharing" button or the cap is reached.
116
+ */
117
+ export async function startRecording({ maxMs = 120_000, onTick, onAutoStop } = {}) {
118
+ const stream = await navigator.mediaDevices.getDisplayMedia({
119
+ video: { frameRate: 15 },
120
+ audio: false,
121
+ });
122
+
123
+ const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
124
+ .find((t) => MediaRecorder.isTypeSupported(t)) || 'video/webm';
125
+
126
+ const recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 2_500_000 });
127
+ const chunks = [];
128
+ recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
129
+ recorder.start(1000);
130
+
131
+ const startedAt = Date.now();
132
+ const timer = setInterval(() => onTick?.(Math.floor((Date.now() - startedAt) / 1000)), 500);
133
+
134
+ const cleanup = () => {
135
+ clearInterval(timer);
136
+ clearTimeout(capTimer);
137
+ stream.getTracks().forEach((t) => t.stop());
138
+ };
139
+
140
+ const finished = new Promise((resolve) => {
141
+ recorder.onstop = () => {
142
+ cleanup();
143
+ resolve(new Blob(chunks, { type: mime }));
144
+ };
145
+ });
146
+
147
+ const endEarly = () => {
148
+ if (recorder.state !== 'inactive') recorder.stop();
149
+ onAutoStop?.();
150
+ };
151
+
152
+ // The user can stop sharing from the browser's own UI; treat that as "done".
153
+ stream.getVideoTracks()[0].addEventListener('ended', endEarly);
154
+
155
+ // Hard cap so a forgotten recording can't grow without bound.
156
+ const capTimer = setTimeout(endEarly, maxMs);
157
+
158
+ return {
159
+ mime,
160
+ maxMs,
161
+ async stop() {
162
+ clearTimeout(capTimer);
163
+ if (recorder.state !== 'inactive') recorder.stop();
164
+ return { blob: await finished, mime, kind: 'VIDEO' };
165
+ },
166
+ cancel() {
167
+ cleanup();
168
+ if (recorder.state !== 'inactive') recorder.stop();
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Record a voice note from the microphone. Returns a handle with stop() and
175
+ * cancel(), like startRecording() above.
176
+ *
177
+ * Audio only, and from getUserMedia rather than getDisplayMedia: this is
178
+ * somebody talking into their laptop to explain a bug, not a capture of the
179
+ * screen. The browser shows its own microphone prompt, so nothing is recorded
180
+ * without an explicit grant.
181
+ *
182
+ * The container is whichever the browser will give us — Chromium and Firefox
183
+ * produce webm/opus, Safari mp4/aac — because MediaRecorder cannot be made to
184
+ * agree on one, and the API allows both.
185
+ */
186
+ export async function startVoiceNote({ maxMs = 120_000, onTick, onAutoStop } = {}) {
187
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
188
+
189
+ const mime = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']
190
+ .find((t) => MediaRecorder.isTypeSupported(t)) || '';
191
+
192
+ const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
193
+ const chunks = [];
194
+ recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
195
+ recorder.start(1000);
196
+
197
+ const startedAt = Date.now();
198
+ const timer = setInterval(() => onTick?.(Math.floor((Date.now() - startedAt) / 1000)), 500);
199
+
200
+ const cleanup = () => {
201
+ clearInterval(timer);
202
+ clearTimeout(capTimer);
203
+ stream.getTracks().forEach((t) => t.stop());
204
+ };
205
+
206
+ const finished = new Promise((resolve) => {
207
+ recorder.onstop = () => {
208
+ cleanup();
209
+ resolve(new Blob(chunks, { type: recorder.mimeType || mime }));
210
+ };
211
+ });
212
+
213
+ const endEarly = () => {
214
+ if (recorder.state !== 'inactive') recorder.stop();
215
+ onAutoStop?.();
216
+ };
217
+
218
+ // A hard cap, as for screen recording: a forgotten open microphone is worse
219
+ // than a truncated note.
220
+ const capTimer = setTimeout(endEarly, maxMs);
221
+
222
+ return {
223
+ mime: recorder.mimeType || mime,
224
+ maxMs,
225
+ async stop() {
226
+ clearTimeout(capTimer);
227
+ if (recorder.state !== 'inactive') recorder.stop();
228
+ const blob = await finished;
229
+ return { blob, mime: blob.type || recorder.mimeType || mime, kind: 'AUDIO' };
230
+ },
231
+ cancel() {
232
+ cleanup();
233
+ if (recorder.state !== 'inactive') recorder.stop();
234
+ },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Ask the API for a presigned URL and PUT the blob straight at it, so media
240
+ * never passes through the API request path.
241
+ *
242
+ * Resolves to the storage key, which is what a report references.
243
+ */
244
+ export async function uploadAttachment({ apiBase, apiKey, blob, mime, onProgress }) {
245
+ const type = normalizeMime(mime || blob.type);
246
+ if (!type) {
247
+ throw new Error(`[BugCatch] ${mime || blob.type || 'that file'} can't be attached.`);
248
+ }
249
+
250
+ const res = await nativeFetch(`${apiBase}/ingest/presign`, {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': apiKey },
253
+ body: JSON.stringify({ mime: type }),
254
+ });
255
+ if (!res.ok) throw new Error(`[BugCatch] could not start the upload (HTTP ${res.status}).`);
256
+ const { key, uploadUrl } = await res.json();
257
+
258
+ await put(uploadUrl, blob, type, onProgress);
259
+ return { storageKey: key, mime: type };
260
+ }
261
+
262
+ /**
263
+ * XHR rather than fetch purely for upload progress — a recording is megabytes,
264
+ * and a progress bar is the difference between "working" and "frozen".
265
+ */
266
+ function put(uploadUrl, blob, mime, onProgress) {
267
+ return new Promise((resolve, reject) => {
268
+ const xhr = new XMLHttpRequest();
269
+ nativeXhrOpen.call(xhr, 'PUT', uploadUrl, true);
270
+ xhr.setRequestHeader('Content-Type', mime);
271
+ xhr.upload.onprogress = (e) => {
272
+ if (e.lengthComputable) onProgress?.(e.loaded / e.total);
273
+ };
274
+ xhr.onload = () => {
275
+ if (xhr.status >= 200 && xhr.status < 300) resolve();
276
+ else reject(new Error(`[BugCatch] upload failed (HTTP ${xhr.status}).`));
277
+ };
278
+ // The PUT goes to the bucket, not to the API, so a missing bucket CORS
279
+ // policy arrives here as a bare network error with no status. Reporting it
280
+ // as "network error" sends people to read API logs that are perfectly clean.
281
+ xhr.onerror = () => reject(new Error(
282
+ '[BugCatch] the browser blocked the upload to storage. If this is a new '
283
+ + 'environment, the media bucket is probably missing its CORS policy.',
284
+ ));
285
+ xhr.onabort = () => reject(new Error('[BugCatch] upload cancelled.'));
286
+ nativeXhrSend.call(xhr, blob);
287
+ });
288
+ }
289
+
290
+ export function humanSize(bytes) {
291
+ if (bytes < 1024) return `${bytes} B`;
292
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
293
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
294
+ }
295
+
296
+ export function humanDuration(seconds) {
297
+ const m = Math.floor(seconds / 60);
298
+ const s = seconds % 60;
299
+ return `${m}:${String(s).padStart(2, '0')}`;
300
+ }