@mieweb/pulsevault 0.5.0 → 0.7.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.
- package/CHANGELOG.md +152 -1
- package/OPERATIONS.md +123 -46
- package/PROTOCOL.md +27 -5
- package/README.md +31 -8
- package/dist/app.d.ts +8 -7
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +4 -3
- package/dist/app.js.map +1 -1
- package/dist/core.d.ts +23 -9
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +106 -21
- package/dist/core.js.map +1 -1
- package/dist/lib/completion.d.ts +22 -5
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +247 -35
- package/dist/lib/completion.js.map +1 -1
- package/dist/lib/deeplinks.d.ts +18 -2
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +13 -2
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/magic.d.ts +41 -1
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +104 -0
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +2 -0
- package/dist/lib/options.d.ts.map +1 -1
- package/dist/lib/options.js +8 -4
- package/dist/lib/options.js.map +1 -1
- package/dist/lib/protocol-version.gen.js +1 -1
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +35 -1
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/web-ready.d.ts +145 -18
- package/dist/lib/web-ready.d.ts.map +1 -1
- package/dist/lib/web-ready.js +484 -91
- package/dist/lib/web-ready.js.map +1 -1
- package/dist/storage/local.d.ts +3 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +310 -50
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts.map +1 -1
- package/dist/storage/s3.js +21 -1
- package/dist/storage/s3.js.map +1 -1
- package/dist/storage/types.d.ts +89 -4
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +7 -0
- package/dist/storage/types.js.map +1 -1
- package/package.json +2 -2
- package/protocol/openapi.json +26 -3
- package/protocol/schemas/artifact-status.schema.json +13 -1
- package/protocol/schemas/deep-link.schema.json +2 -2
- package/scripts/web-ready-migrate.mjs +35 -11
package/dist/lib/web-ready.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
+
* Conform uploaded videos to one web-playable format: the one the Pulse app records.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
95
|
-
|
|
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;
|
|
96
189
|
try {
|
|
97
|
-
const { stdout }
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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;
|
|
236
|
+
try {
|
|
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
|
-
|
|
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 =
|
|
285
|
+
cached = query(binPath, ['-version']).then(() => true, () => false);
|
|
119
286
|
binaryAvailable.set(binPath, cached);
|
|
120
287
|
}
|
|
121
288
|
return cached;
|
|
122
289
|
}
|
|
123
|
-
/**
|
|
124
|
-
async function
|
|
125
|
-
const
|
|
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(
|
|
357
|
+
const tmp = path.join(path.dirname(inputPath), `.webready-${randomUUID()}${CONFORM_TARGET.extension}`);
|
|
131
358
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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,78 @@ async function rewriteInPlace(filePath, ffmpegPath, args) {
|
|
|
147
387
|
}
|
|
148
388
|
}
|
|
149
389
|
/**
|
|
150
|
-
*
|
|
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
|
+
/** Longest edge of a poster frame, in pixels: a card's size, not the video's. */
|
|
400
|
+
const POSTER_MAX_EDGE = 640;
|
|
401
|
+
const POSTER_TIMEOUT_MS = 60_000;
|
|
402
|
+
/** A second in, past a fade from black; a shorter clip falls back to its first frame. */
|
|
403
|
+
const POSTER_SEEK_SECONDS = [1, 0];
|
|
404
|
+
/**
|
|
405
|
+
* One frame of a video as a JPEG, for a video that arrived without a thumbnail (a file from a
|
|
406
|
+
* host's own picker): its longest edge at most 640 px, turned the way the video plays. Resolves
|
|
407
|
+
* `null` when there's no frame to take (no video stream, ffmpeg missing or failing); the video
|
|
408
|
+
* then has no poster, and nothing else changes.
|
|
409
|
+
*/
|
|
410
|
+
export async function extractPosterFrame(filePath, options = {}) {
|
|
411
|
+
const ffmpegPath = options.ffmpegPath ?? 'ffmpeg';
|
|
412
|
+
if (!(await isBinaryAvailable(ffmpegPath)))
|
|
413
|
+
return null;
|
|
414
|
+
const scale = `scale='min(${POSTER_MAX_EDGE},iw)':'min(${POSTER_MAX_EDGE},ih)':force_original_aspect_ratio=decrease`;
|
|
415
|
+
for (const seconds of POSTER_SEEK_SECONDS) {
|
|
416
|
+
try {
|
|
417
|
+
const { stdout } = await execFileAsync(ffmpegPath, ['-v', 'error', '-ss', String(seconds), '-i', filePath, '-frames:v', '1', '-vf', scale]
|
|
418
|
+
.concat(['-pix_fmt', 'yuvj420p', '-q:v', '3', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']), { encoding: 'buffer', maxBuffer: 8 * 1024 * 1024, timeout: POSTER_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
419
|
+
// A seek past the end exits 0 with no frame: try the next one.
|
|
420
|
+
if (stdout.length > 0)
|
|
421
|
+
return stdout;
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
options.logger?.error({ err, filePath }, 'pulsevault could not take a poster frame');
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
/** Fail at once on an option that can't work, for `ensureWebReady` and the core's `webReady`. */
|
|
431
|
+
export function validateWebReadyOptions(options) {
|
|
432
|
+
const { maxEdge, timeoutSeconds, probeTimeoutSeconds } = options;
|
|
433
|
+
if (maxEdge !== undefined && !(Number.isInteger(maxEdge) && maxEdge >= 2)) {
|
|
434
|
+
throw new TypeError('`webReady.maxEdge` must be an integer of at least 2 (pixels)');
|
|
435
|
+
}
|
|
436
|
+
for (const [name, value] of [['timeoutSeconds', timeoutSeconds], ['probeTimeoutSeconds', probeTimeoutSeconds]]) {
|
|
437
|
+
if (value !== undefined && !(value > 0 && Number.isFinite(value))) {
|
|
438
|
+
throw new TypeError(`\`webReady.${name}\` must be a positive number of seconds`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Bring the video at `filePath` to `CONFORM_TARGET`:
|
|
151
444
|
*
|
|
152
|
-
* -
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
445
|
+
* - already conforming → `none`, bytes untouched;
|
|
446
|
+
* - an MP4 with conforming streams but the `moov` at the end → lossless faststart remux in
|
|
447
|
+
* place (`remuxed`);
|
|
448
|
+
* - any other container with conforming streams → lossless remux into a new `.mp4` beside it
|
|
449
|
+
* (`conformed`);
|
|
450
|
+
* - otherwise one ffmpeg run: the video re-encoded to H.264 when its codec, pixel format, bit
|
|
451
|
+
* depth, size, rotation or dynamic range is off (scaled by the longest edge, rotation
|
|
452
|
+
* applied, HDR tone-mapped), the audio re-encoded to AAC unless it already is — in place for
|
|
453
|
+
* an MP4 (`transcoded`), into a new `.mp4` otherwise (`conformed`);
|
|
454
|
+
* - no ffmpeg/ffprobe, a file ffprobe can't read, a failed or timed-out run → `skipped` with
|
|
455
|
+
* the reason; the original is untouched.
|
|
158
456
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
457
|
+
* When the result carries `outputPath`, the conformed file was written there (`<name>.mp4`)
|
|
458
|
+
* and the original is left in place: the caller switches the artifact over, then removes it.
|
|
459
|
+
* With `install: false` nothing is renamed: every rewrite is left at a temporary path beside
|
|
460
|
+
* the input, returned as `outputPath`, for the caller to install (or remove).
|
|
461
|
+
* Never throws for pipeline reasons.
|
|
163
462
|
*
|
|
164
463
|
* NOTE: a rewrite changes the artifact's bytes, so any upload-time checksum
|
|
165
464
|
* recorded for it (e.g. the local adapter's sidecar `checksum` from
|
|
@@ -168,56 +467,150 @@ async function rewriteInPlace(filePath, ffmpegPath, args) {
|
|
|
168
467
|
* upload-time provenance, not current-file integrity.
|
|
169
468
|
*/
|
|
170
469
|
export async function ensureWebReady(filePath, options = {}) {
|
|
470
|
+
validateWebReadyOptions(options);
|
|
171
471
|
const ffmpegPath = options.ffmpegPath ?? 'ffmpeg';
|
|
172
472
|
const ffprobePath = options.ffprobePath ?? 'ffprobe';
|
|
173
473
|
const transcode = options.transcode ?? true;
|
|
474
|
+
const install = options.install ?? true;
|
|
475
|
+
const maxEdge = options.maxEdge ?? CONFORM_TARGET.maxEdge;
|
|
174
476
|
const crf = options.crf ?? 23;
|
|
175
477
|
const preset = options.preset ?? 'veryfast';
|
|
176
|
-
|
|
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))) {
|
|
478
|
+
if (!(await webReadyAvailable({ ffmpegPath, ffprobePath }))) {
|
|
181
479
|
const key = `${ffmpegPath}|${ffprobePath}`;
|
|
182
480
|
if (!warnedMissing.has(key)) {
|
|
183
481
|
warnedMissing.add(key);
|
|
184
482
|
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
|
|
483
|
+
'Install ffmpeg (apt install ffmpeg / brew install ffmpeg) to conform videos for the web.');
|
|
186
484
|
}
|
|
187
485
|
return { action: 'skipped', reason: 'ffmpeg/ffprobe not available' };
|
|
188
486
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
}
|
|
487
|
+
let probe;
|
|
488
|
+
try {
|
|
489
|
+
probe = await probeVideo(filePath, { ffprobePath, timeoutSeconds: options.probeTimeoutSeconds });
|
|
205
490
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
491
|
+
catch (err) {
|
|
492
|
+
if (!(err instanceof ProbeTimeoutError))
|
|
493
|
+
throw err;
|
|
494
|
+
options.logger?.error({ err, filePath }, 'pulsevault web-ready: ffprobe timed out; serving original bytes');
|
|
495
|
+
return { action: 'skipped', reason: err.message };
|
|
496
|
+
}
|
|
497
|
+
if (!probe)
|
|
498
|
+
return { action: 'skipped', reason: 'not a video ffprobe can read' };
|
|
499
|
+
// Phones only record quarter turns; any other angle would need its own geometry.
|
|
500
|
+
if (probe.video.rotation % 90 !== 0) {
|
|
501
|
+
return { action: 'skipped', reason: `rotation ${probe.video.rotation}° isn't a quarter turn` };
|
|
502
|
+
}
|
|
503
|
+
const { video, audio } = probe;
|
|
504
|
+
const moov = await scanMoovPosition(filePath);
|
|
505
|
+
const isMp4 = path.extname(filePath).toLowerCase() === CONFORM_TARGET.extension && moov !== 'unknown';
|
|
506
|
+
// What's off-target, stream by stream.
|
|
507
|
+
const hdr = video.transfer !== undefined && HDR_TRANSFERS.has(video.transfer);
|
|
508
|
+
const size = targetSize(video, maxEdge);
|
|
509
|
+
const videoOff = [];
|
|
510
|
+
if (video.codec !== CONFORM_TARGET.videoCodec)
|
|
511
|
+
videoOff.push(`codec ${video.codec}`);
|
|
512
|
+
if (video.pixelFormat !== CONFORM_TARGET.pixelFormat)
|
|
513
|
+
videoOff.push(`pixel format ${video.pixelFormat}`);
|
|
514
|
+
else if (video.range === 'pc')
|
|
515
|
+
videoOff.push('full range');
|
|
516
|
+
if (hdr)
|
|
517
|
+
videoOff.push(`HDR (${video.transfer})`);
|
|
518
|
+
if (video.rotation !== 0)
|
|
519
|
+
videoOff.push(`rotation ${video.rotation}°`);
|
|
520
|
+
if (video.rotation === 0 && (size.width !== video.width || size.height !== video.height)) {
|
|
521
|
+
videoOff.push(`size ${video.width}×${video.height}`);
|
|
522
|
+
}
|
|
523
|
+
const audioOff = audio !== null && audio.codec !== CONFORM_TARGET.audioCodec;
|
|
524
|
+
if (videoOff.length === 0 && !audioOff && isMp4 && moov === 'front') {
|
|
525
|
+
return { action: 'none', reason: 'already web-ready' };
|
|
526
|
+
}
|
|
527
|
+
if ((videoOff.length > 0 || audioOff) && !transcode) {
|
|
528
|
+
const off = [...videoOff, ...(audioOff ? [`audio ${audio?.codec}`] : [])].join(', ');
|
|
529
|
+
if (isMp4 && moov === 'end') {
|
|
530
|
+
try {
|
|
531
|
+
const written = await rewrite(filePath, install ? filePath : null, ffmpegPath, ['-c', 'copy', '-map', '0', '-movflags', '+faststart'], timeoutOf(options, probe));
|
|
532
|
+
return {
|
|
533
|
+
action: 'remuxed',
|
|
534
|
+
reason: `moov was at end of file; remuxed to faststart (${off} left as-is: transcode disabled)`,
|
|
535
|
+
...(install ? {} : { outputPath: written }),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
options.logger?.error({ err, filePath }, 'pulsevault web-ready: faststart remux failed; serving original bytes');
|
|
540
|
+
return { action: 'skipped', reason: `remux failed: ${failureOf(err)}` };
|
|
541
|
+
}
|
|
214
542
|
}
|
|
543
|
+
// Not `none`: that means the file already conforms. This one is left off-target on purpose.
|
|
544
|
+
return { action: 'skipped', reason: `${off} left as-is (transcode disabled)` };
|
|
215
545
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
546
|
+
// HDR without a way to tone-map it would come out with wrong colours tagged as right: keep
|
|
547
|
+
// the original and say what's missing instead.
|
|
548
|
+
const toneMapper = hdr ? await toneMapperOf(ffmpegPath) : null;
|
|
549
|
+
if (hdr && !toneMapper) {
|
|
550
|
+
options.logger?.error({ filePath }, 'pulsevault web-ready: HDR video, but this ffmpeg can\'t tone-map; serving original bytes');
|
|
551
|
+
return {
|
|
552
|
+
action: 'skipped',
|
|
553
|
+
reason: `HDR (${video.transfer}) needs tone mapping, which this ffmpeg can't do: FFmpeg 8 or later, or a build with zscale`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const args = ['-map', `0:${video.index}`, ...(audio ? ['-map', `0:${audio.index}`] : []), '-sn', '-dn'];
|
|
557
|
+
const done = [];
|
|
558
|
+
if (videoOff.length > 0) {
|
|
559
|
+
args.push('-vf', videoFilter(size, hdr, toneMapper), '-c:v', 'libx264', '-preset', preset, '-crf', String(crf));
|
|
560
|
+
// An 8-bit SDR output must not keep the source's HDR colour tags.
|
|
561
|
+
if (hdr)
|
|
562
|
+
args.push('-color_primaries', 'bt709', '-color_trc', 'bt709', '-colorspace', 'bt709');
|
|
563
|
+
const toneMapped = hdr ? ', tone-mapped' : '';
|
|
564
|
+
done.push(`video ${videoOff.join(', ')} → h264 ${size.width}×${size.height}${toneMapped}`);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
args.push('-c:v', 'copy');
|
|
568
|
+
}
|
|
569
|
+
if (audioOff) {
|
|
570
|
+
args.push('-c:a', 'aac', '-b:a', '160k');
|
|
571
|
+
done.push(`audio ${audio?.codec} → aac`);
|
|
572
|
+
}
|
|
573
|
+
else if (audio) {
|
|
574
|
+
args.push('-c:a', 'copy');
|
|
575
|
+
}
|
|
576
|
+
args.push('-movflags', '+faststart');
|
|
577
|
+
const outputPath = isMp4
|
|
578
|
+
? filePath
|
|
579
|
+
: path.join(path.dirname(filePath), `${path.basename(filePath, path.extname(filePath))}${CONFORM_TARGET.extension}`);
|
|
580
|
+
const action = !isMp4 ? 'conformed' : done.length > 0 ? 'transcoded' : 'remuxed';
|
|
581
|
+
if (!isMp4) {
|
|
582
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
583
|
+
done.unshift(`container ${ext && ext !== CONFORM_TARGET.extension ? ext : 'not MP4'} → mp4`);
|
|
584
|
+
}
|
|
585
|
+
if (isMp4 && moov === 'end')
|
|
586
|
+
done.push('moov moved to the front');
|
|
587
|
+
let written;
|
|
588
|
+
try {
|
|
589
|
+
written = await rewrite(filePath, install ? outputPath : null, ffmpegPath, args, timeoutOf(options, probe));
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
options.logger?.error({ err, filePath }, `pulsevault web-ready: ${action === 'remuxed' ? 'remux' : 'conversion'} failed; serving original bytes`);
|
|
593
|
+
return { action: 'skipped', reason: `${action === 'remuxed' ? 'remux' : 'conversion'} failed: ${failureOf(err)}` };
|
|
594
|
+
}
|
|
595
|
+
const reason = action === 'remuxed' ? 'moov was at end of file; remuxed to faststart' : `${done.join('; ')} (+faststart)`;
|
|
596
|
+
return { action, reason, ...(written !== filePath ? { outputPath: written } : {}) };
|
|
597
|
+
}
|
|
598
|
+
/** The longest default run: the default is computed from the upload's own (untrusted) metadata. */
|
|
599
|
+
const MAX_DEFAULT_TIMEOUT_SECONDS = 3600;
|
|
600
|
+
/** The wall-clock limit for one run over this video, in ms. */
|
|
601
|
+
function timeoutOf(options, probe) {
|
|
602
|
+
const computed = probe.durationSeconds !== null ? 60 + 10 * workSeconds(probe) : MAX_DEFAULT_TIMEOUT_SECONDS;
|
|
603
|
+
const seconds = options.timeoutSeconds ??
|
|
604
|
+
(Number.isFinite(computed) ? Math.min(computed, MAX_DEFAULT_TIMEOUT_SECONDS) : MAX_DEFAULT_TIMEOUT_SECONDS);
|
|
605
|
+
return Math.max(1, Math.round(seconds * 1000));
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* The video's duration in seconds of 1080p30, the work of a conversion: a second of 4K at
|
|
609
|
+
* 120 fps (an iPhone recording) decodes and scales 16 times the pixels of a 1080p30 second.
|
|
610
|
+
*/
|
|
611
|
+
function workSeconds(probe) {
|
|
612
|
+
const { width, height, frameRate } = probe.video;
|
|
613
|
+
const pixelRate = width * height * (frameRate ?? 30);
|
|
614
|
+
return (probe.durationSeconds ?? 0) * Math.max(1, pixelRate / (1920 * 1080 * 30));
|
|
222
615
|
}
|
|
223
616
|
//# sourceMappingURL=web-ready.js.map
|