@mieweb/pulsevault 0.5.0 → 0.6.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +127 -1
  2. package/OPERATIONS.md +123 -46
  3. package/PROTOCOL.md +27 -5
  4. package/README.md +28 -8
  5. package/dist/app.d.ts +7 -6
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +4 -3
  8. package/dist/app.js.map +1 -1
  9. package/dist/core.d.ts +22 -8
  10. package/dist/core.d.ts.map +1 -1
  11. package/dist/core.js +90 -17
  12. package/dist/core.js.map +1 -1
  13. package/dist/lib/completion.d.ts +8 -5
  14. package/dist/lib/completion.d.ts.map +1 -1
  15. package/dist/lib/completion.js +121 -34
  16. package/dist/lib/completion.js.map +1 -1
  17. package/dist/lib/deeplinks.d.ts +18 -2
  18. package/dist/lib/deeplinks.d.ts.map +1 -1
  19. package/dist/lib/deeplinks.js +13 -2
  20. package/dist/lib/deeplinks.js.map +1 -1
  21. package/dist/lib/magic.d.ts +41 -1
  22. package/dist/lib/magic.d.ts.map +1 -1
  23. package/dist/lib/magic.js +104 -0
  24. package/dist/lib/magic.js.map +1 -1
  25. package/dist/lib/options.d.ts +2 -0
  26. package/dist/lib/options.d.ts.map +1 -1
  27. package/dist/lib/options.js +8 -4
  28. package/dist/lib/options.js.map +1 -1
  29. package/dist/lib/protocol-version.gen.js +1 -1
  30. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  31. package/dist/lib/pulsevaultTus.js +35 -1
  32. package/dist/lib/pulsevaultTus.js.map +1 -1
  33. package/dist/lib/web-ready.d.ts +138 -18
  34. package/dist/lib/web-ready.d.ts.map +1 -1
  35. package/dist/lib/web-ready.js +453 -91
  36. package/dist/lib/web-ready.js.map +1 -1
  37. package/dist/storage/local.d.ts.map +1 -1
  38. package/dist/storage/local.js +241 -50
  39. package/dist/storage/local.js.map +1 -1
  40. package/dist/storage/s3.d.ts.map +1 -1
  41. package/dist/storage/s3.js +21 -1
  42. package/dist/storage/s3.js.map +1 -1
  43. package/dist/storage/types.d.ts +57 -4
  44. package/dist/storage/types.d.ts.map +1 -1
  45. package/dist/storage/types.js +7 -0
  46. package/dist/storage/types.js.map +1 -1
  47. package/package.json +2 -2
  48. package/protocol/openapi.json +26 -3
  49. package/protocol/schemas/artifact-status.schema.json +13 -1
  50. package/protocol/schemas/deep-link.schema.json +2 -2
  51. package/scripts/web-ready-migrate.mjs +35 -11
@@ -1,31 +1,59 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { createInterface } from 'node:readline';
5
6
  import { promisify } from 'node:util';
6
7
  const execFileAsync = promisify(execFile);
7
8
  /**
8
- * Web-playability backstop for uploaded MP4 artifacts.
9
+ * Conform uploaded videos to one web-playable format: the one the Pulse app records.
9
10
  *
10
- * Browsers streaming an MP4 over progressive HTTP need two things the mobile
11
- * capture pipelines frequently don't provide:
11
+ * A video reaches PulseVault from the Pulse app (already in the target format) or from a host's
12
+ * own file picker: a screen recording, an iPhone HEVC `.mov` (often 10-bit HDR), a WebM from a
13
+ * browser recorder, a 4K clip. Browsers streaming over progressive HTTP need the `moov` atom at
14
+ * the front, a codec they all decode, and 8-bit SDR colour; a phone needs a sane size.
12
15
  *
13
- * 1. The `moov` atom at the FRONT of the file ("faststart"). iOS/Android
14
- * recorders finalize the moov at the end, so a `<video>` tag must fetch the
15
- * file's tail before it can render frame one — a multi-second stall behind
16
- * Range requests, or a full download in naive players.
17
- * 2. A codec browsers actually decode. iPhones record HEVC by default, which
18
- * Firefox never plays and Chrome usually can't without hardware support.
19
- *
20
- * `ensureWebReady` fixes both in place: a lossless sub-second remux
21
- * (`-c copy -movflags +faststart`) when only the moov position is wrong, and a
22
- * one-time H.264 transcode when the codec is hostile. Fail-open by design —
23
- * if `ffmpeg`/`ffprobe` are not installed, or the file isn't MP4-family, the
24
- * artifact is left byte-for-byte as uploaded and serving continues exactly as
25
- * before.
16
+ * `ensureWebReady` does the least work that gets a file there: nothing when it already
17
+ * conforms (a Pulse upload keeps its bytes), a lossless remux when only the container or the
18
+ * `moov` position is off, and otherwise one ffmpeg run that re-encodes only the streams that
19
+ * are off-target. Fail-open by design: without `ffmpeg`/`ffprobe`, or when a run fails or
20
+ * times out, the original bytes keep serving and the result says why.
21
+ */
22
+ /**
23
+ * The format every video is served in. "Already conforms" checks exactly these properties —
24
+ * never the orientation: a landscape video stays landscape and a portrait one portrait, with no
25
+ * crop, pad or stretch.
26
26
  */
27
- /** Codecs every mainstream browser decodes without hardware caveats. */
28
- const WEB_SAFE_VIDEO_CODECS = new Set(['h264']);
27
+ export const CONFORM_TARGET = {
28
+ /** MP4 with the `moov` atom at the front (faststart). */
29
+ container: 'mp4',
30
+ extension: '.mp4',
31
+ /**
32
+ * H.264, 8-bit 4:2:0 in limited range, even width and height, SDR, no rotation tag. HDR (PQ,
33
+ * HLG) is tone-mapped to BT.709; an SDR source keeps its own colour tags (BT.709 or BT.601),
34
+ * which browsers honour — so a Pulse upload from any phone keeps its bytes.
35
+ */
36
+ videoCodec: 'h264',
37
+ pixelFormat: 'yuv420p',
38
+ /** Default cap on the longest edge, in pixels (`webReady.maxEdge`). */
39
+ maxEdge: 1920,
40
+ /** AAC; a video without audio stays silent. */
41
+ audioCodec: 'aac',
42
+ };
43
+ /** The containers the conform step turns into the target: the default video `allowedExtensions`. */
44
+ export const CONFORM_VIDEO_EXTENSIONS = [
45
+ '.mp4',
46
+ '.mov',
47
+ '.m4v',
48
+ '.webm',
49
+ '.mkv',
50
+ '.3gp',
51
+ '.avi',
52
+ ];
53
+ /** ffprobe demuxers that read still or animated images, not video. */
54
+ const IMAGE_FORMATS = /^(image2|[a-z0-9]+_pipe|gif|apng|webp)$/;
55
+ /** Transfer functions of HDR video (PQ and HLG): tone-mapped to SDR on the way in. */
56
+ const HDR_TRANSFERS = new Set(['smpte2084', 'arib-std-b67']);
29
57
  /**
30
58
  * Walk the file's top-level ISO-BMFF boxes and report whether `moov` appears
31
59
  * before or after `mdat`. Pure Node (a handful of 16-byte header reads) — no
@@ -33,7 +61,7 @@ const WEB_SAFE_VIDEO_CODECS = new Set(['h264']);
33
61
  * costs microseconds regardless of file size.
34
62
  *
35
63
  * Returns `"unknown"` for non-MP4 bytes, truncated headers, or files missing
36
- * either box — callers should treat that as "leave the file alone".
64
+ * either box.
37
65
  */
38
66
  export async function scanMoovPosition(filePath) {
39
67
  let fd;
@@ -56,7 +84,7 @@ export async function scanMoovPosition(filePath) {
56
84
  let boxSize = header.readUInt32BE(0);
57
85
  const boxType = header.toString('latin1', 4, 8);
58
86
  if (first) {
59
- // Anything that doesn't open with ftyp isn't MP4-family — don't touch it.
87
+ // Anything that doesn't open with ftyp isn't MP4-family.
60
88
  if (boxType !== 'ftyp')
61
89
  return 'unknown';
62
90
  first = false;
@@ -91,55 +119,267 @@ export async function scanMoovPosition(filePath) {
91
119
  await fd.close();
92
120
  }
93
121
  }
94
- /** ffprobe the first video stream's codec name; `null` when unprobeable. */
95
- async function probeVideoCodec(filePath, ffprobePath) {
122
+ /** An ffprobe run past its `timeoutSeconds`: the file couldn't be read in time. */
123
+ export class ProbeTimeoutError extends Error {
124
+ }
125
+ const DEFAULT_PROBE_TIMEOUT_SECONDS = 60;
126
+ /**
127
+ * Degrees as a display rotation in (-180, 180]: the nearest quarter turn when within a degree of
128
+ * one (270 → -90, -180 → 180, 360 → 0, 89.9 → 90), else the angle itself (45 → 45).
129
+ */
130
+ const displayRotation = (degrees) => {
131
+ const turns = Math.round(degrees / 90);
132
+ if (Math.abs(degrees - turns * 90) <= 1)
133
+ return [0, 90, 180, -90][((turns % 4) + 4) % 4] ?? 0;
134
+ const angle = ((degrees % 360) + 360) % 360;
135
+ return angle > 180 ? angle - 360 : angle;
136
+ };
137
+ /** ffprobe's `120/1` or `30000/1001` as frames per second; `undefined` for `0/0`. */
138
+ const frameRateOf = (ratio) => {
139
+ const [num, den] = (ratio ?? '').split('/').map(Number);
140
+ const fps = num && den ? num / den : NaN;
141
+ return Number.isFinite(fps) && fps > 0 ? fps : undefined;
142
+ };
143
+ const positive = (value) => {
144
+ const n = typeof value === 'number' ? value : Number.parseFloat(String(value));
145
+ return Number.isFinite(n) && n > 0 ? n : null;
146
+ };
147
+ /**
148
+ * Run ffprobe on an untrusted upload, bounded: killed past `timeoutMs` (`ProbeTimeoutError`).
149
+ * Resolves its stdout, or `null` when ffprobe fails on the file.
150
+ */
151
+ async function runProbe(ffprobePath, args, timeoutMs) {
152
+ try {
153
+ // Small, bounded output only (the metadata, two packets); the packet scan streams instead.
154
+ const { stdout } = await execFileAsync(ffprobePath, ['-v', 'error', ...args], {
155
+ maxBuffer: 16 * 1024 * 1024,
156
+ timeout: timeoutMs,
157
+ killSignal: 'SIGKILL',
158
+ });
159
+ return stdout;
160
+ }
161
+ catch (err) {
162
+ if (err.killed) {
163
+ throw new ProbeTimeoutError(`ffprobe timed out after ${Math.round(timeoutMs / 100) / 10} s`);
164
+ }
165
+ return null;
166
+ }
167
+ }
168
+ /**
169
+ * The span of the video packets — from the first packet's start to the last one's end — for a
170
+ * file whose header carries no duration (a WebM from a browser's MediaRecorder is written without
171
+ * one). Timestamps needn't start at zero, and may be negative. Demuxes without decoding.
172
+ */
173
+ async function packetDuration(filePath, ffprobePath, index, timeoutMs) {
174
+ // One line per packet, read as it comes and kept only as the earliest start and latest end: a
175
+ // crafted file with millions of tiny packets costs time (bounded by the timeout), never memory.
176
+ const child = spawn(ffprobePath, ['-v', 'error', '-select_streams', String(index), '-show_entries', 'packet=pts_time,duration_time', '-of', 'csv=p=0', filePath], { stdio: ['ignore', 'pipe', 'ignore'] });
177
+ // Settles once the process is gone, whether it exited or never started (a bad path).
178
+ const exited = new Promise((resolve) => {
179
+ child.once('close', (exitCode) => resolve(exitCode));
180
+ child.once('error', () => resolve(null));
181
+ });
182
+ let timedOut = false;
183
+ const timer = setTimeout(() => {
184
+ timedOut = true;
185
+ child.kill('SIGKILL');
186
+ }, timeoutMs);
187
+ let start = Infinity;
188
+ let end = -Infinity;
189
+ try {
190
+ for await (const line of createInterface({ input: child.stdout })) {
191
+ const [pts, duration] = line.split(',');
192
+ const at = Number.parseFloat(pts ?? '');
193
+ if (!Number.isFinite(at))
194
+ continue; // `N/A`
195
+ start = Math.min(start, at);
196
+ end = Math.max(end, at + (positive(duration) ?? 0));
197
+ }
198
+ const code = await exited;
199
+ if (timedOut)
200
+ throw new ProbeTimeoutError(`ffprobe timed out after ${Math.round(timeoutMs / 100) / 10} s`);
201
+ if (code !== 0)
202
+ return null;
203
+ }
204
+ finally {
205
+ clearTimeout(timer);
206
+ }
207
+ const span = end - start;
208
+ return Number.isFinite(span) && span > 0 ? span : null;
209
+ }
210
+ /** Whether a stream has at least two packets: reads only the first two. */
211
+ async function hasTwoPackets(filePath, ffprobePath, index, timeoutMs) {
212
+ const stdout = await runProbe(ffprobePath, ['-select_streams', String(index), '-read_intervals', '%+#2', '-show_entries', 'packet=pts', '-of', 'csv=p=0', filePath], timeoutMs);
213
+ return (stdout ?? '').split('\n').filter((line) => line.trim() !== '').length >= 2;
214
+ }
215
+ /**
216
+ * ffprobe a file: its first real video stream (not cover art), its first audio stream and its
217
+ * duration. `null` when it isn't a video: ffprobe can't read it, it has no video stream, or it
218
+ * is a picture (an image file, or a stream of one frame). Each ffprobe run is bounded by
219
+ * `timeoutSeconds`; past it, rejects with `ProbeTimeoutError`.
220
+ */
221
+ export async function probeVideo(filePath, options = {}) {
222
+ const ffprobePath = options.ffprobePath ?? 'ffprobe';
223
+ // At least 1 ms: Node treats a timeout of 0 as none.
224
+ const timeoutMs = Math.max(1, Math.round((options.timeoutSeconds ?? DEFAULT_PROBE_TIMEOUT_SECONDS) * 1000));
225
+ const stdout = await runProbe(ffprobePath, [
226
+ '-show_entries',
227
+ 'stream=index,codec_type,codec_name,pix_fmt,width,height,color_transfer,color_range,nb_frames,avg_frame_rate' +
228
+ ':stream_disposition=attached_pic:stream_side_data=rotation:stream_tags=rotate' +
229
+ ':format=duration,format_name',
230
+ '-of', 'json',
231
+ filePath,
232
+ ], timeoutMs);
233
+ if (stdout === null)
234
+ return null;
235
+ let parsed;
96
236
  try {
97
- const { stdout } = await execFileAsync(ffprobePath, [
98
- '-v', 'error',
99
- '-select_streams', 'v:0',
100
- '-show_entries', 'stream=codec_name',
101
- '-of', 'default=noprint_wrappers=1:nokey=1',
102
- filePath,
103
- ]);
104
- const codec = stdout.trim();
105
- return codec.length > 0 ? codec : null;
237
+ parsed = JSON.parse(stdout);
106
238
  }
107
239
  catch {
108
240
  return null;
109
241
  }
242
+ const streams = parsed.streams ?? [];
243
+ const video = streams.find((s) => s.codec_type === 'video' && !s.disposition?.attached_pic && s.width && s.height);
244
+ if (!video || typeof video.index !== 'number')
245
+ return null;
246
+ // A picture isn't a video: an image file (PNG, JPEG, GIF, WebP) read by an image demuxer, or
247
+ // a single-frame stream (an iPhone HEIC photo is an ISO-BMFF file with one HEVC frame). A
248
+ // container that doesn't record a frame count (MKV, WebM) is asked for its first two packets.
249
+ if (IMAGE_FORMATS.test(parsed.format?.format_name ?? ''))
250
+ return null;
251
+ const frames = Number(video.nb_frames);
252
+ if (Number.isFinite(frames) ? frames < 2 : !(await hasTwoPackets(filePath, ffprobePath, video.index, timeoutMs))) {
253
+ return null;
254
+ }
255
+ const audio = streams.find((s) => s.codec_type === 'audio');
256
+ const sideRotation = video.side_data_list?.find((d) => d.rotation !== undefined)?.rotation;
257
+ const rotation = Number(sideRotation ?? video.tags?.rotate ?? 0) || 0;
258
+ const durationSeconds = positive(parsed.format?.duration) ?? (await packetDuration(filePath, ffprobePath, video.index, timeoutMs));
259
+ return {
260
+ video: {
261
+ index: video.index,
262
+ codec: video.codec_name ?? '',
263
+ pixelFormat: video.pix_fmt ?? '',
264
+ width: video.width ?? 0,
265
+ height: video.height ?? 0,
266
+ ...(video.color_transfer ? { transfer: video.color_transfer } : {}),
267
+ ...(video.color_range && video.color_range !== 'unknown' ? { range: video.color_range } : {}),
268
+ rotation: displayRotation(rotation),
269
+ ...(frameRateOf(video.avg_frame_rate) ? { frameRate: frameRateOf(video.avg_frame_rate) } : {}),
270
+ },
271
+ audio: audio && typeof audio.index === 'number' ? { index: audio.index, codec: audio.codec_name ?? '' } : null,
272
+ durationSeconds,
273
+ };
110
274
  }
111
275
  // One availability probe per (binary path) per process — a missing ffmpeg
112
276
  // should cost one spawn and one warning, not one of each per upload.
113
277
  const binaryAvailable = new Map();
114
278
  const warnedMissing = new Set();
115
- function isBinaryAvailable(binPath) {
279
+ /** Capability queries (`-version`, `-h`, `-filters`) answer at once; one that hangs is killed. */
280
+ const QUERY_TIMEOUT_MS = 30_000;
281
+ const query = (binPath, args) => execFileAsync(binPath, args, { timeout: QUERY_TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 });
282
+ export function isBinaryAvailable(binPath) {
116
283
  let cached = binaryAvailable.get(binPath);
117
284
  if (!cached) {
118
- cached = execFileAsync(binPath, ['-version']).then(() => true, () => false);
285
+ cached = query(binPath, ['-version']).then(() => true, () => false);
119
286
  binaryAvailable.set(binPath, cached);
120
287
  }
121
288
  return cached;
122
289
  }
123
- /** Run ffmpeg writing to a sibling tmp file, then atomically replace the original. */
124
- async function rewriteInPlace(filePath, ffmpegPath, args) {
125
- const dir = path.dirname(filePath);
290
+ /** Whether this host can conform videos: both `ffmpeg` and `ffprobe` run. */
291
+ export async function webReadyAvailable(options = {}) {
292
+ const [ffmpeg, ffprobe] = await Promise.all([
293
+ isBinaryAvailable(options.ffmpegPath ?? 'ffmpeg'),
294
+ isBinaryAvailable(options.ffprobePath ?? 'ffprobe'),
295
+ ]);
296
+ return ffmpeg && ffprobe;
297
+ }
298
+ const toneMappers = new Map();
299
+ function toneMapperOf(ffmpegPath) {
300
+ let cached = toneMappers.get(ffmpegPath);
301
+ if (!cached) {
302
+ cached = (async () => {
303
+ try {
304
+ const { stdout: scaleHelp } = await query(ffmpegPath, ['-hide_banner', '-h', 'filter=scale']);
305
+ if (scaleHelp.includes('out_transfer'))
306
+ return 'scale';
307
+ const { stdout: filters } = await query(ffmpegPath, ['-hide_banner', '-filters']);
308
+ return /\szscale\s/.test(filters) ? 'zscale' : null;
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ })();
314
+ toneMappers.set(ffmpegPath, cached);
315
+ }
316
+ return cached;
317
+ }
318
+ /**
319
+ * Rounded to whole pixels, then down to even (at least 2): never above the limit it was scaled
320
+ * to (an odd `maxEdge` stays a cap), and an odd source edge loses a pixel rather than gaining one.
321
+ */
322
+ const even = (n) => Math.max(2, 2 * Math.floor(Math.round(n) / 2));
323
+ /** The displayed size (after rotation) scaled so the longest edge is at most `maxEdge`. */
324
+ function targetSize(video, maxEdge) {
325
+ const sideways = Math.abs(video.rotation) === 90;
326
+ const width = sideways ? video.height : video.width;
327
+ const height = sideways ? video.width : video.height;
328
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
329
+ return { width: even(width * scale), height: even(height * scale) };
330
+ }
331
+ /** The `-vf` chain for a re-encode: tone map (HDR), scale to the target size, 8-bit 4:2:0. */
332
+ function videoFilter(size, hdr, toneMapper) {
333
+ const { width, height } = size;
334
+ if (hdr && toneMapper === 'scale') {
335
+ return (`scale=w=${width}:h=${height}:out_transfer=bt709:out_primaries=bt709` +
336
+ ':out_color_matrix=bt709:out_range=tv:intent=perceptual,format=yuv420p');
337
+ }
338
+ if (hdr && toneMapper === 'zscale') {
339
+ return ('zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,' +
340
+ `zscale=t=bt709:m=bt709:r=tv,format=yuv420p,scale=${width}:${height}`);
341
+ }
342
+ // `out_range=tv`: a full-range source (`yuvj420p`, as iPhone screen recordings are) is
343
+ // converted to the limited range of `yuv420p`; `format` alone keeps the source's range.
344
+ return `scale=${width}:${height}:out_range=tv,format=yuv420p`;
345
+ }
346
+ class TimeoutError extends Error {
347
+ }
348
+ /**
349
+ * Run ffmpeg into a sibling tmp file, then rename it onto `outputPath` (the input itself, or a
350
+ * new `.mp4` beside it). The rename is atomic, so a crash never leaves a half-written artifact.
351
+ */
352
+ async function rewrite(inputPath, outputPath, ffmpegPath, args, timeoutMs) {
126
353
  // randomUUID in the name: every video artifact shares one kind directory, so
127
354
  // a pid+timestamp tmp name collides when two uploads finish in the same
128
355
  // millisecond — both ffmpegs would interleave writes into one file and the
129
356
  // winner's rename would install garbage bytes over a real artifact.
130
- const tmp = path.join(dir, `.webready-${randomUUID()}${path.extname(filePath) || '.mp4'}`);
357
+ const tmp = path.join(path.dirname(inputPath), `.webready-${randomUUID()}${CONFORM_TARGET.extension}`);
131
358
  try {
132
- await execFileAsync(ffmpegPath, ['-y', '-i', filePath, ...args, tmp], {
133
- // A transcode of a long upload legitimately takes minutes; cap the
134
- // buffered stderr instead of the wall clock.
135
- maxBuffer: 16 * 1024 * 1024,
136
- });
359
+ try {
360
+ await execFileAsync(ffmpegPath, ['-y', '-v', 'error', '-i', inputPath, ...args, tmp], {
361
+ // Cap the buffered stderr; the wall clock is capped by the timeout.
362
+ maxBuffer: 16 * 1024 * 1024,
363
+ timeout: timeoutMs,
364
+ killSignal: 'SIGKILL',
365
+ });
366
+ }
367
+ catch (err) {
368
+ if (err.killed) {
369
+ throw new TimeoutError(`timed out after ${Math.round(timeoutMs / 100) / 10} s`);
370
+ }
371
+ throw err;
372
+ }
137
373
  // ffmpeg exiting 0 with an empty/absent output would still be a corrupt
138
374
  // swap — verify there are real bytes before replacing the original.
139
375
  const stat = await fs.stat(tmp);
140
376
  if (stat.size === 0)
141
377
  throw new Error('ffmpeg produced an empty output');
142
- await fs.rename(tmp, filePath);
378
+ // `null`: the caller installs the file itself (`install: false`).
379
+ if (outputPath === null)
380
+ return tmp;
381
+ await fs.rename(tmp, outputPath);
382
+ return outputPath;
143
383
  }
144
384
  catch (err) {
145
385
  await fs.rm(tmp, { force: true });
@@ -147,19 +387,47 @@ async function rewriteInPlace(filePath, ffmpegPath, args) {
147
387
  }
148
388
  }
149
389
  /**
150
- * Make the MP4 at `filePath` web-playable, in place:
390
+ * A failed run, in words fit for the status route: never ffmpeg's stderr, which names server
391
+ * paths (the full error goes to the log).
392
+ */
393
+ function failureOf(err) {
394
+ if (err instanceof TimeoutError)
395
+ return err.message;
396
+ const code = err.code;
397
+ return typeof code === 'number' ? `ffmpeg exited with code ${code}` : 'ffmpeg could not write the file';
398
+ }
399
+ /** Fail at once on an option that can't work, for `ensureWebReady` and the core's `webReady`. */
400
+ export function validateWebReadyOptions(options) {
401
+ const { maxEdge, timeoutSeconds, probeTimeoutSeconds } = options;
402
+ if (maxEdge !== undefined && !(Number.isInteger(maxEdge) && maxEdge >= 2)) {
403
+ throw new TypeError('`webReady.maxEdge` must be an integer of at least 2 (pixels)');
404
+ }
405
+ for (const [name, value] of [['timeoutSeconds', timeoutSeconds], ['probeTimeoutSeconds', probeTimeoutSeconds]]) {
406
+ if (value !== undefined && !(value > 0 && Number.isFinite(value))) {
407
+ throw new TypeError(`\`webReady.${name}\` must be a positive number of seconds`);
408
+ }
409
+ }
410
+ }
411
+ /**
412
+ * Bring the video at `filePath` to `CONFORM_TARGET`:
151
413
  *
152
- * - moov at the end + web-safe codec → lossless `-c copy -movflags +faststart`
153
- * remux (sub-second, no quality change);
154
- * - non-web-safe codec (HEVC etc.) → one-time `libx264` transcode (audio
155
- * stream-copied) with faststart;
156
- * - already faststart H.264, non-MP4 bytes, unprobeable files, or missing
157
- * ffmpeg/ffprobe → untouched (`skipped`/`none`).
414
+ * - already conforming → `none`, bytes untouched;
415
+ * - an MP4 with conforming streams but the `moov` at the end → lossless faststart remux in
416
+ * place (`remuxed`);
417
+ * - any other container with conforming streams → lossless remux into a new `.mp4` beside it
418
+ * (`conformed`);
419
+ * - otherwise one ffmpeg run: the video re-encoded to H.264 when its codec, pixel format, bit
420
+ * depth, size, rotation or dynamic range is off (scaled by the longest edge, rotation
421
+ * applied, HDR tone-mapped), the audio re-encoded to AAC unless it already is — in place for
422
+ * an MP4 (`transcoded`), into a new `.mp4` otherwise (`conformed`);
423
+ * - no ffmpeg/ffprobe, a file ffprobe can't read, a failed or timed-out run → `skipped` with
424
+ * the reason; the original is untouched.
158
425
  *
159
- * The rewrite is atomic (tmp file + rename in the same directory), so a crash
160
- * mid-way never corrupts the served artifact. Never throws for pipeline
161
- * reasons — a failed ffmpeg run resolves to `skipped` with the reason, and the
162
- * original bytes keep serving.
426
+ * When the result carries `outputPath`, the conformed file was written there (`<name>.mp4`)
427
+ * and the original is left in place: the caller switches the artifact over, then removes it.
428
+ * With `install: false` nothing is renamed: every rewrite is left at a temporary path beside
429
+ * the input, returned as `outputPath`, for the caller to install (or remove).
430
+ * Never throws for pipeline reasons.
163
431
  *
164
432
  * NOTE: a rewrite changes the artifact's bytes, so any upload-time checksum
165
433
  * recorded for it (e.g. the local adapter's sidecar `checksum` from
@@ -168,56 +436,150 @@ async function rewriteInPlace(filePath, ffmpegPath, args) {
168
436
  * upload-time provenance, not current-file integrity.
169
437
  */
170
438
  export async function ensureWebReady(filePath, options = {}) {
439
+ validateWebReadyOptions(options);
171
440
  const ffmpegPath = options.ffmpegPath ?? 'ffmpeg';
172
441
  const ffprobePath = options.ffprobePath ?? 'ffprobe';
173
442
  const transcode = options.transcode ?? true;
443
+ const install = options.install ?? true;
444
+ const maxEdge = options.maxEdge ?? CONFORM_TARGET.maxEdge;
174
445
  const crf = options.crf ?? 23;
175
446
  const preset = options.preset ?? 'veryfast';
176
- const moov = await scanMoovPosition(filePath);
177
- if (moov === 'unknown') {
178
- return { action: 'skipped', reason: 'not an MP4-family file (or no moov/mdat found)' };
179
- }
180
- if (!(await isBinaryAvailable(ffprobePath)) || !(await isBinaryAvailable(ffmpegPath))) {
447
+ if (!(await webReadyAvailable({ ffmpegPath, ffprobePath }))) {
181
448
  const key = `${ffmpegPath}|${ffprobePath}`;
182
449
  if (!warnedMissing.has(key)) {
183
450
  warnedMissing.add(key);
184
451
  options.logger?.error({ filePath }, 'pulsevault web-ready: ffmpeg/ffprobe not found — uploads are served as-is. ' +
185
- 'Install ffmpeg (apt install ffmpeg / brew install ffmpeg) to enable faststart remux and H.264 transcode.');
452
+ 'Install ffmpeg (apt install ffmpeg / brew install ffmpeg) to conform videos for the web.');
186
453
  }
187
454
  return { action: 'skipped', reason: 'ffmpeg/ffprobe not available' };
188
455
  }
189
- const codec = await probeVideoCodec(filePath, ffprobePath);
190
- const codecHostile = codec !== null && !WEB_SAFE_VIDEO_CODECS.has(codec);
191
- if (codecHostile && transcode) {
192
- try {
193
- await rewriteInPlace(filePath, ffmpegPath, [
194
- '-c:v', 'libx264', '-preset', preset, '-crf', String(crf),
195
- '-pix_fmt', 'yuv420p',
196
- '-c:a', 'copy',
197
- '-movflags', '+faststart',
198
- ]);
199
- return { action: 'transcoded', reason: `video codec ${codec} → h264 (+faststart)` };
200
- }
201
- catch (err) {
202
- options.logger?.error({ err, filePath }, 'pulsevault web-ready: transcode failed; serving original bytes');
203
- return { action: 'skipped', reason: `transcode failed: ${err.message}` };
204
- }
456
+ let probe;
457
+ try {
458
+ probe = await probeVideo(filePath, { ffprobePath, timeoutSeconds: options.probeTimeoutSeconds });
205
459
  }
206
- if (moov === 'end') {
207
- try {
208
- await rewriteInPlace(filePath, ffmpegPath, ['-c', 'copy', '-movflags', '+faststart']);
209
- return { action: 'remuxed', reason: 'moov was at end of file; remuxed to faststart' };
210
- }
211
- catch (err) {
212
- options.logger?.error({ err, filePath }, 'pulsevault web-ready: faststart remux failed; serving original bytes');
213
- return { action: 'skipped', reason: `remux failed: ${err.message}` };
460
+ catch (err) {
461
+ if (!(err instanceof ProbeTimeoutError))
462
+ throw err;
463
+ options.logger?.error({ err, filePath }, 'pulsevault web-ready: ffprobe timed out; serving original bytes');
464
+ return { action: 'skipped', reason: err.message };
465
+ }
466
+ if (!probe)
467
+ return { action: 'skipped', reason: 'not a video ffprobe can read' };
468
+ // Phones only record quarter turns; any other angle would need its own geometry.
469
+ if (probe.video.rotation % 90 !== 0) {
470
+ return { action: 'skipped', reason: `rotation ${probe.video.rotation}° isn't a quarter turn` };
471
+ }
472
+ const { video, audio } = probe;
473
+ const moov = await scanMoovPosition(filePath);
474
+ const isMp4 = path.extname(filePath).toLowerCase() === CONFORM_TARGET.extension && moov !== 'unknown';
475
+ // What's off-target, stream by stream.
476
+ const hdr = video.transfer !== undefined && HDR_TRANSFERS.has(video.transfer);
477
+ const size = targetSize(video, maxEdge);
478
+ const videoOff = [];
479
+ if (video.codec !== CONFORM_TARGET.videoCodec)
480
+ videoOff.push(`codec ${video.codec}`);
481
+ if (video.pixelFormat !== CONFORM_TARGET.pixelFormat)
482
+ videoOff.push(`pixel format ${video.pixelFormat}`);
483
+ else if (video.range === 'pc')
484
+ videoOff.push('full range');
485
+ if (hdr)
486
+ videoOff.push(`HDR (${video.transfer})`);
487
+ if (video.rotation !== 0)
488
+ videoOff.push(`rotation ${video.rotation}°`);
489
+ if (video.rotation === 0 && (size.width !== video.width || size.height !== video.height)) {
490
+ videoOff.push(`size ${video.width}×${video.height}`);
491
+ }
492
+ const audioOff = audio !== null && audio.codec !== CONFORM_TARGET.audioCodec;
493
+ if (videoOff.length === 0 && !audioOff && isMp4 && moov === 'front') {
494
+ return { action: 'none', reason: 'already web-ready' };
495
+ }
496
+ if ((videoOff.length > 0 || audioOff) && !transcode) {
497
+ const off = [...videoOff, ...(audioOff ? [`audio ${audio?.codec}`] : [])].join(', ');
498
+ if (isMp4 && moov === 'end') {
499
+ try {
500
+ const written = await rewrite(filePath, install ? filePath : null, ffmpegPath, ['-c', 'copy', '-map', '0', '-movflags', '+faststart'], timeoutOf(options, probe));
501
+ return {
502
+ action: 'remuxed',
503
+ reason: `moov was at end of file; remuxed to faststart (${off} left as-is: transcode disabled)`,
504
+ ...(install ? {} : { outputPath: written }),
505
+ };
506
+ }
507
+ catch (err) {
508
+ options.logger?.error({ err, filePath }, 'pulsevault web-ready: faststart remux failed; serving original bytes');
509
+ return { action: 'skipped', reason: `remux failed: ${failureOf(err)}` };
510
+ }
214
511
  }
512
+ // Not `none`: that means the file already conforms. This one is left off-target on purpose.
513
+ return { action: 'skipped', reason: `${off} left as-is (transcode disabled)` };
215
514
  }
216
- return {
217
- action: 'none',
218
- reason: codecHostile
219
- ? `already faststart; codec ${codec} left as-is (transcode disabled)`
220
- : 'already web-ready',
221
- };
515
+ // HDR without a way to tone-map it would come out with wrong colours tagged as right: keep
516
+ // the original and say what's missing instead.
517
+ const toneMapper = hdr ? await toneMapperOf(ffmpegPath) : null;
518
+ if (hdr && !toneMapper) {
519
+ options.logger?.error({ filePath }, 'pulsevault web-ready: HDR video, but this ffmpeg can\'t tone-map; serving original bytes');
520
+ return {
521
+ action: 'skipped',
522
+ reason: `HDR (${video.transfer}) needs tone mapping, which this ffmpeg can't do: FFmpeg 8 or later, or a build with zscale`,
523
+ };
524
+ }
525
+ const args = ['-map', `0:${video.index}`, ...(audio ? ['-map', `0:${audio.index}`] : []), '-sn', '-dn'];
526
+ const done = [];
527
+ if (videoOff.length > 0) {
528
+ args.push('-vf', videoFilter(size, hdr, toneMapper), '-c:v', 'libx264', '-preset', preset, '-crf', String(crf));
529
+ // An 8-bit SDR output must not keep the source's HDR colour tags.
530
+ if (hdr)
531
+ args.push('-color_primaries', 'bt709', '-color_trc', 'bt709', '-colorspace', 'bt709');
532
+ const toneMapped = hdr ? ', tone-mapped' : '';
533
+ done.push(`video ${videoOff.join(', ')} → h264 ${size.width}×${size.height}${toneMapped}`);
534
+ }
535
+ else {
536
+ args.push('-c:v', 'copy');
537
+ }
538
+ if (audioOff) {
539
+ args.push('-c:a', 'aac', '-b:a', '160k');
540
+ done.push(`audio ${audio?.codec} → aac`);
541
+ }
542
+ else if (audio) {
543
+ args.push('-c:a', 'copy');
544
+ }
545
+ args.push('-movflags', '+faststart');
546
+ const outputPath = isMp4
547
+ ? filePath
548
+ : path.join(path.dirname(filePath), `${path.basename(filePath, path.extname(filePath))}${CONFORM_TARGET.extension}`);
549
+ const action = !isMp4 ? 'conformed' : done.length > 0 ? 'transcoded' : 'remuxed';
550
+ if (!isMp4) {
551
+ const ext = path.extname(filePath).toLowerCase();
552
+ done.unshift(`container ${ext && ext !== CONFORM_TARGET.extension ? ext : 'not MP4'} → mp4`);
553
+ }
554
+ if (isMp4 && moov === 'end')
555
+ done.push('moov moved to the front');
556
+ let written;
557
+ try {
558
+ written = await rewrite(filePath, install ? outputPath : null, ffmpegPath, args, timeoutOf(options, probe));
559
+ }
560
+ catch (err) {
561
+ options.logger?.error({ err, filePath }, `pulsevault web-ready: ${action === 'remuxed' ? 'remux' : 'conversion'} failed; serving original bytes`);
562
+ return { action: 'skipped', reason: `${action === 'remuxed' ? 'remux' : 'conversion'} failed: ${failureOf(err)}` };
563
+ }
564
+ const reason = action === 'remuxed' ? 'moov was at end of file; remuxed to faststart' : `${done.join('; ')} (+faststart)`;
565
+ return { action, reason, ...(written !== filePath ? { outputPath: written } : {}) };
566
+ }
567
+ /** The longest default run: the default is computed from the upload's own (untrusted) metadata. */
568
+ const MAX_DEFAULT_TIMEOUT_SECONDS = 3600;
569
+ /** The wall-clock limit for one run over this video, in ms. */
570
+ function timeoutOf(options, probe) {
571
+ const computed = probe.durationSeconds !== null ? 60 + 10 * workSeconds(probe) : MAX_DEFAULT_TIMEOUT_SECONDS;
572
+ const seconds = options.timeoutSeconds ??
573
+ (Number.isFinite(computed) ? Math.min(computed, MAX_DEFAULT_TIMEOUT_SECONDS) : MAX_DEFAULT_TIMEOUT_SECONDS);
574
+ return Math.max(1, Math.round(seconds * 1000));
575
+ }
576
+ /**
577
+ * The video's duration in seconds of 1080p30, the work of a conversion: a second of 4K at
578
+ * 120 fps (an iPhone recording) decodes and scales 16 times the pixels of a 1080p30 second.
579
+ */
580
+ function workSeconds(probe) {
581
+ const { width, height, frameRate } = probe.video;
582
+ const pixelRate = width * height * (frameRate ?? 30);
583
+ return (probe.durationSeconds ?? 0) * Math.max(1, pixelRate / (1920 * 1080 * 30));
222
584
  }
223
585
  //# sourceMappingURL=web-ready.js.map