@mrkt_frwd/reel 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/getting-started.md +280 -0
- package/package.json +24 -0
- package/src/actions.mjs +215 -0
- package/src/assemble.mjs +362 -0
- package/src/caption.mjs +115 -0
- package/src/capture.mjs +143 -0
- package/src/cli.mjs +318 -0
- package/src/clock.mjs +131 -0
- package/src/critic.mjs +271 -0
- package/src/cursor.mjs +118 -0
- package/src/edit-cli.mjs +211 -0
- package/src/edit.mjs +303 -0
- package/src/frame-grid.mjs +80 -0
- package/src/index.mjs +19 -0
- package/src/motion.mjs +229 -0
- package/src/runner.mjs +205 -0
- package/src/schema.mjs +157 -0
- package/src/server.mjs +109 -0
- package/src/timeline.mjs +120 -0
package/src/assemble.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frames to deliverable video.
|
|
3
|
+
*
|
|
4
|
+
* Encodes the master at a constant frame rate from the irregular capture, then derives
|
|
5
|
+
* the formats the reference briefs call for: a landscape MP4 for product demos, a 9:16
|
|
6
|
+
* crop for social, and a GIF for the landing hover-preview that `index.html` and
|
|
7
|
+
* `shells/registry.json` already swap in when a `preview` field is set.
|
|
8
|
+
*
|
|
9
|
+
* `yuv420p` and the even-dimension scale filter are not decoration: without them Safari
|
|
10
|
+
* and most social uploaders refuse the file or re-encode it badly, and an odd pixel
|
|
11
|
+
* dimension makes libx264 fail outright.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { createRequire } from 'module';
|
|
17
|
+
|
|
18
|
+
// The director's-name → filter-name map lives with the edit vocabulary. Imported rather
|
|
19
|
+
// than restated: two copies of a mapping table drift, and the drift is invisible until an
|
|
20
|
+
// edit names a transition one file knows about and the other does not.
|
|
21
|
+
import { TRANSITIONS } from './edit.mjs';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
function ffmpegBin() {
|
|
26
|
+
if (process.env.FFMPEG) return process.env.FFMPEG;
|
|
27
|
+
try {
|
|
28
|
+
return require('ffmpeg-static');
|
|
29
|
+
} catch {
|
|
30
|
+
return 'ffmpeg';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function run(args, label) {
|
|
35
|
+
const bin = ffmpegBin();
|
|
36
|
+
const res = spawnSync(bin, ['-hide_banner', '-loglevel', 'error', '-y', ...args], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
if (res.error) throw new Error(`${label}: could not run ffmpeg (${res.error.message})`);
|
|
41
|
+
if (res.status !== 0) throw new Error(`${label}: ${(res.stderr || '').slice(-600)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EVEN = 'scale=trunc(iw/2)*2:trunc(ih/2)*2';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.manifest ffmpeg concat file written by the recorder
|
|
49
|
+
* @param {string} opts.out master MP4 path
|
|
50
|
+
* @param {number} [opts.fps] constant output rate
|
|
51
|
+
* @param {number} [opts.crf] 18 is a master; 23 is a web deliverable
|
|
52
|
+
*/
|
|
53
|
+
export function encodeMaster({ manifest, out, fps = 30, crf = 18 }) {
|
|
54
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
55
|
+
run([
|
|
56
|
+
'-f', 'concat', '-safe', '0', '-i', manifest,
|
|
57
|
+
'-vsync', 'cfr', '-r', String(fps),
|
|
58
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', String(crf),
|
|
59
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
60
|
+
'-vf', EVEN,
|
|
61
|
+
out,
|
|
62
|
+
], 'encode master');
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 9:16 for social. Crops to the centre of interest rather than letterboxing, because a
|
|
68
|
+
* letterboxed product demo reads as an afterthought in a vertical feed. `focusX` is a
|
|
69
|
+
* 0-1 fraction — a left-hand form or a right-hand 3D stage is rarely centred.
|
|
70
|
+
*/
|
|
71
|
+
export function encodeVertical({ input, out, focusX = 0.5, fps = 30, crf = 20 }) {
|
|
72
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
73
|
+
const x = `(iw-ow)*${Math.max(0, Math.min(1, focusX)).toFixed(3)}`;
|
|
74
|
+
run([
|
|
75
|
+
'-i', input,
|
|
76
|
+
'-vf', `crop=ih*9/16:ih:${x}:0,scale=1080:1920:flags=lanczos,${EVEN}`,
|
|
77
|
+
'-r', String(fps),
|
|
78
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', String(crf),
|
|
79
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
80
|
+
out,
|
|
81
|
+
], 'encode vertical');
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Two-pass palette GIF. A single-pass GIF of a dark UI bands badly. */
|
|
86
|
+
export function encodeGif({ input, out, width = 720, fps = 15 }) {
|
|
87
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
88
|
+
const palette = path.join(path.dirname(out), '.palette.png');
|
|
89
|
+
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
|
90
|
+
run(['-i', input, '-vf', `${filters},palettegen=stats_mode=diff`, palette], 'gif palette');
|
|
91
|
+
run([
|
|
92
|
+
'-i', input, '-i', palette,
|
|
93
|
+
'-lavfi', `${filters}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`,
|
|
94
|
+
out,
|
|
95
|
+
], 'gif encode');
|
|
96
|
+
fs.rmSync(palette, { force: true });
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A handful of stills spread across the runtime, for a human to glance at.
|
|
102
|
+
*
|
|
103
|
+
* Not what the critic reads — see `extractFrames`, which pulls a dense strip. These are
|
|
104
|
+
* for the reviewer who wants to see four or five moments without opening the video.
|
|
105
|
+
*/
|
|
106
|
+
export function sampleFrames({ input, dir, count = 6, duration }) {
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
// Spread the samples across the whole runtime. The obvious `thumbnail,fps=1/1` with a
|
|
110
|
+
// frame cap does not do this — it takes the first N seconds and stops, so every sample
|
|
111
|
+
// of a ninety-second tour came from the opening shot and the later shots were never
|
|
112
|
+
// looked at. A critique built on that is worse than none, because it reads as coverage.
|
|
113
|
+
const seconds = duration || probeDuration(input) || count;
|
|
114
|
+
const rate = count / Math.max(seconds, 0.001);
|
|
115
|
+
|
|
116
|
+
run([
|
|
117
|
+
'-i', input,
|
|
118
|
+
'-vf', `fps=${rate.toFixed(6)},scale=1280:-1`,
|
|
119
|
+
'-frames:v', String(count),
|
|
120
|
+
path.join(dir, 'sample-%02d.png'),
|
|
121
|
+
], 'sample frames');
|
|
122
|
+
|
|
123
|
+
return fs.readdirSync(dir)
|
|
124
|
+
.filter((f) => f.startsWith('sample-'))
|
|
125
|
+
.sort()
|
|
126
|
+
.map((f) => path.join(dir, f));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Dense frames for the critic, extracted from the finished master.
|
|
131
|
+
*
|
|
132
|
+
* Taken from the encoded video rather than the capture frames on purpose: the critic
|
|
133
|
+
* should judge what a viewer will actually see, after resampling and compression, not an
|
|
134
|
+
* intermediate nobody watches. Four per second is enough to catch a frozen shot or a jump
|
|
135
|
+
* without making the analysis cost more than the recording.
|
|
136
|
+
*/
|
|
137
|
+
export function extractFrames({ input, dir, fps = 4 }) {
|
|
138
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
run([
|
|
141
|
+
'-i', input,
|
|
142
|
+
'-vf', `fps=${fps},scale=480:-1:flags=bilinear`,
|
|
143
|
+
path.join(dir, 'a-%05d.png'),
|
|
144
|
+
], 'extract analysis frames');
|
|
145
|
+
return fs.readdirSync(dir).filter((f) => f.startsWith('a-')).sort().map((f) => path.join(dir, f));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Cut an edit from a master: trim each segment, retime it, burn its caption in, crop to
|
|
150
|
+
* the target aspect, and concatenate.
|
|
151
|
+
*
|
|
152
|
+
* Done as one filtergraph rather than encoding each segment to a file and concatenating.
|
|
153
|
+
* Two reasons: a re-encode per segment costs quality at every cut, and the concat demuxer
|
|
154
|
+
* needs every input to share codec parameters — which a per-segment crop or speed change
|
|
155
|
+
* quietly breaks, producing a file that plays only the first segment in some players.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} opts
|
|
158
|
+
* @param {string} opts.input master MP4
|
|
159
|
+
* @param {string} opts.out
|
|
160
|
+
* @param {{start:number,end:number,speed:number,captionPng?:string}[]} opts.segments
|
|
161
|
+
* @param {{w:number,h:number}} opts.size target frame
|
|
162
|
+
* @param {number} [opts.focusX] 0-1, where the crop sits when narrowing the frame
|
|
163
|
+
*/
|
|
164
|
+
export function cutEdit({
|
|
165
|
+
input, out, segments, size, focusX = 0.5, fps = 30, crf = 19,
|
|
166
|
+
background = '#0b0e12', audio = null,
|
|
167
|
+
}) {
|
|
168
|
+
const bg = background;
|
|
169
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
170
|
+
|
|
171
|
+
const { filter, videoLabel, audioLabel, inputs } = buildEditGraph({
|
|
172
|
+
input, segments, size, focusX, fps, background: bg, audio,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
run([
|
|
176
|
+
...inputs,
|
|
177
|
+
'-filter_complex', filter,
|
|
178
|
+
'-map', videoLabel,
|
|
179
|
+
...(audioLabel ? ['-map', audioLabel, '-c:a', 'aac', '-b:a', '160k'] : []),
|
|
180
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', String(crf),
|
|
181
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
182
|
+
'-r', String(fps),
|
|
183
|
+
out,
|
|
184
|
+
], 'cut edit');
|
|
185
|
+
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the filtergraph for an edit, separately from running it.
|
|
191
|
+
*
|
|
192
|
+
* Split out so the graph can be asserted without a video file or an ffmpeg process. The
|
|
193
|
+
* failure this guards against is a mislabelled chain that still encodes — ffmpeg produces
|
|
194
|
+
* a perfectly valid file with a segment missing, and the only other way to notice is to
|
|
195
|
+
* compare the output duration against what the edit asked for, after the render.
|
|
196
|
+
*
|
|
197
|
+
* @returns {{ filter: string, videoLabel: string, audioLabel: string|null, inputs: string[] }}
|
|
198
|
+
*/
|
|
199
|
+
export function buildEditGraph({ input, segments, size, focusX = 0.5, fps = 30, background = '#0b0e12', audio = null }) {
|
|
200
|
+
const bg = background;
|
|
201
|
+
const inputs = ['-i', input];
|
|
202
|
+
const captionIdx = new Map();
|
|
203
|
+
let nextInput = 1;
|
|
204
|
+
|
|
205
|
+
for (const seg of segments) {
|
|
206
|
+
// An animated overlay is a numbered PNG sequence with alpha, fed in at the edit's
|
|
207
|
+
// frame rate. It takes precedence over the still: a segment declaring motion has
|
|
208
|
+
// already had its caption rendered as frames, and compositing both would double the
|
|
209
|
+
// type.
|
|
210
|
+
if (seg.motionSeq?.count > 0) {
|
|
211
|
+
inputs.push(
|
|
212
|
+
'-framerate', String(seg.motionSeq.fps),
|
|
213
|
+
'-i', path.join(seg.motionSeq.dir, 'm-%05d.png')
|
|
214
|
+
);
|
|
215
|
+
captionIdx.set(seg, nextInput++);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!seg.captionPng) continue;
|
|
219
|
+
// Looped rather than dropped in as a single frame: a still has no timeline, so it can
|
|
220
|
+
// neither fade nor be scheduled. Giving it the segment's duration turns the caption
|
|
221
|
+
// into a clip, which is what makes captionAt / captionForSec / captionFadeSec mean
|
|
222
|
+
// anything at all.
|
|
223
|
+
inputs.push('-loop', '1', '-framerate', String(fps), '-t', seg.outSec.toFixed(4), '-i', seg.captionPng);
|
|
224
|
+
captionIdx.set(seg, nextInput++);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const audioIdx = audio?.file ? nextInput++ : null;
|
|
228
|
+
if (audioIdx != null) {
|
|
229
|
+
// A short music bed under a longer cut ends in silence unless it repeats.
|
|
230
|
+
if (audio.loop) inputs.push('-stream_loop', '-1');
|
|
231
|
+
inputs.push('-i', audio.file);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const parts = [];
|
|
235
|
+
const labels = [];
|
|
236
|
+
|
|
237
|
+
segments.forEach((seg, i) => {
|
|
238
|
+
const v = `v${i}`;
|
|
239
|
+
const fx = Math.max(0, Math.min(1, seg.focusX ?? focusX));
|
|
240
|
+
const x = `(iw-ow)*${fx.toFixed(3)}`;
|
|
241
|
+
// Crop to the target ratio *before* scaling so the framing decision is made on the
|
|
242
|
+
// source pixels, then scale once.
|
|
243
|
+
const chain = [
|
|
244
|
+
`trim=start=${seg.start.toFixed(4)}:end=${seg.end.toFixed(4)}`,
|
|
245
|
+
'setpts=PTS-STARTPTS',
|
|
246
|
+
...(seg.speed && seg.speed !== 1 ? [`setpts=PTS/${seg.speed}`] : []),
|
|
247
|
+
...(seg.fit === "contain"
|
|
248
|
+
// Letterbox instead of cropping. A 1440x900 master narrowed to 9:16 keeps only 35%
|
|
249
|
+
// of its width, which frames a centred 3D stage well and slices the left column off
|
|
250
|
+
// a two-column form. Where the layout *is* the subject, showing all of it on a
|
|
251
|
+
// studio-coloured ground beats showing a third of it edge to edge.
|
|
252
|
+
? [`scale=${size.w}:${size.h}:force_original_aspect_ratio=decrease:flags=lanczos`,
|
|
253
|
+
`pad=${size.w}:${size.h}:(ow-iw)/2:(oh-ih)/2:color=${bg}`]
|
|
254
|
+
: [`crop=min(iw\\,ih*${size.w}/${size.h}):min(ih\\,iw*${size.h}/${size.w}):${x}:(ih-oh)/2`,
|
|
255
|
+
`scale=${size.w}:${size.h}:flags=lanczos`]),
|
|
256
|
+
'setsar=1',
|
|
257
|
+
`fps=${fps}`,
|
|
258
|
+
].join(',');
|
|
259
|
+
parts.push(`[0:v]${chain}[${v}]`);
|
|
260
|
+
|
|
261
|
+
if (captionIdx.has(seg) && seg.motionSeq?.count > 0) {
|
|
262
|
+
// The sequence carries its own fades and timing, so it is scaled and composited as
|
|
263
|
+
// it is — gating or fading it here would fight the animation that was rendered.
|
|
264
|
+
const ci = captionIdx.get(seg);
|
|
265
|
+
const at = Math.max(0, seg.captionAt ?? 0);
|
|
266
|
+
parts.push(`[${ci}:v]scale=${size.w}:${size.h},setsar=1,format=rgba[c${i}]`);
|
|
267
|
+
const shift = at > 0 ? `:enable='gte(t,${at.toFixed(3)})'` : '';
|
|
268
|
+
parts.push(`[${v}][c${i}]overlay=0:0:format=auto:eof_action=pass${shift}[o${i}]`);
|
|
269
|
+
labels.push(`[o${i}]`);
|
|
270
|
+
} else if (captionIdx.has(seg)) {
|
|
271
|
+
const ci = captionIdx.get(seg);
|
|
272
|
+
const at = Math.max(0, seg.captionAt ?? 0);
|
|
273
|
+
const until = seg.captionForSec != null
|
|
274
|
+
? Math.min(at + seg.captionForSec, seg.outSec)
|
|
275
|
+
: seg.outSec;
|
|
276
|
+
const fade = Math.min(seg.captionFadeSec ?? 0, (until - at) / 2);
|
|
277
|
+
|
|
278
|
+
const capChain = [`scale=${size.w}:${size.h}`, 'setsar=1'];
|
|
279
|
+
if (fade > 0) {
|
|
280
|
+
// Alpha fades, not colour fades: a caption on a transparent plate faded to black
|
|
281
|
+
// becomes a black rectangle over the shot.
|
|
282
|
+
capChain.push('format=rgba');
|
|
283
|
+
capChain.push(`fade=t=in:st=${at.toFixed(3)}:d=${fade.toFixed(3)}:alpha=1`);
|
|
284
|
+
capChain.push(`fade=t=out:st=${(until - fade).toFixed(3)}:d=${fade.toFixed(3)}:alpha=1`);
|
|
285
|
+
}
|
|
286
|
+
parts.push(`[${ci}:v]${capChain.join(',')}[c${i}]`);
|
|
287
|
+
|
|
288
|
+
// `enable` gates the overlay; the fades soften its edges. Both are needed — enable
|
|
289
|
+
// alone pops, fade alone leaves the plate composited (and costing quality) for the
|
|
290
|
+
// whole segment even at zero alpha.
|
|
291
|
+
const windowed = at > 0 || seg.captionForSec != null;
|
|
292
|
+
const enable = windowed ? `:enable='between(t,${at.toFixed(3)},${until.toFixed(3)})'` : '';
|
|
293
|
+
parts.push(`[${v}][c${i}]overlay=0:0:format=auto${enable}[o${i}]`);
|
|
294
|
+
labels.push(`[o${i}]`);
|
|
295
|
+
} else {
|
|
296
|
+
labels.push(`[${v}]`);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Fold the segments together left to right rather than concatenating in one go, because
|
|
301
|
+
// a transition is pairwise: xfade takes exactly two streams and overlaps them. Folding
|
|
302
|
+
// lets cuts and transitions mix freely in one edit — concat where the edit says cut,
|
|
303
|
+
// xfade where it names a transition — which a single concat node cannot express.
|
|
304
|
+
let cursor = labels[0];
|
|
305
|
+
let accSec = segments[0]?.outSec ?? 0;
|
|
306
|
+
|
|
307
|
+
for (let i = 1; i < segments.length; i++) {
|
|
308
|
+
const seg = segments[i];
|
|
309
|
+
const tr = seg.transition ?? { type: 'cut', durationSec: 0 };
|
|
310
|
+
const next = `x${i}`;
|
|
311
|
+
|
|
312
|
+
if (tr.type !== 'cut' && tr.durationSec > 0) {
|
|
313
|
+
// offset is measured on the *first* input's timeline, so it is where the outgoing
|
|
314
|
+
// stream should start dissolving: its full length so far, less the overlap.
|
|
315
|
+
const offset = Math.max(0, accSec - tr.durationSec);
|
|
316
|
+
parts.push(
|
|
317
|
+
`${cursor}${labels[i]}xfade=transition=${TRANSITIONS[tr.type] || tr.type}`
|
|
318
|
+
+ `:duration=${tr.durationSec.toFixed(3)}:offset=${offset.toFixed(4)}[${next}]`
|
|
319
|
+
);
|
|
320
|
+
accSec = accSec + seg.outSec - tr.durationSec;
|
|
321
|
+
} else {
|
|
322
|
+
parts.push(`${cursor}${labels[i]}concat=n=2:v=1:a=0[${next}]`);
|
|
323
|
+
accSec += seg.outSec;
|
|
324
|
+
}
|
|
325
|
+
cursor = `[${next}]`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
parts.push(`${cursor}null[outv]`);
|
|
329
|
+
|
|
330
|
+
let audioLabel = null;
|
|
331
|
+
if (audioIdx != null) {
|
|
332
|
+
const total = accSec;
|
|
333
|
+
const gain = audio.gainDb ?? -18;
|
|
334
|
+
const fadeIn = audio.fadeInSec ?? 0.5;
|
|
335
|
+
const fadeOut = Math.min(audio.fadeOutSec ?? 1, total);
|
|
336
|
+
const chain = [
|
|
337
|
+
`atrim=start=0:end=${total.toFixed(4)}`,
|
|
338
|
+
'asetpts=PTS-STARTPTS',
|
|
339
|
+
// Default well under the voice of the piece. A bed that competes with the subject is
|
|
340
|
+
// the most common way a good demo is made unwatchable.
|
|
341
|
+
`volume=${gain}dB`,
|
|
342
|
+
...(fadeIn > 0 ? [`afade=t=in:st=0:d=${fadeIn.toFixed(3)}`] : []),
|
|
343
|
+
...(fadeOut > 0 ? [`afade=t=out:st=${Math.max(0, total - fadeOut).toFixed(3)}:d=${fadeOut.toFixed(3)}`] : []),
|
|
344
|
+
// Guarantees a full-length track even when the bed is short and not set to loop,
|
|
345
|
+
// so the muxer never truncates the video to the audio.
|
|
346
|
+
`apad=whole_dur=${total.toFixed(4)}`,
|
|
347
|
+
];
|
|
348
|
+
parts.push(`[${audioIdx}:a]${chain.join(',')}[outa]`);
|
|
349
|
+
audioLabel = '[outa]';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { filter: parts.join(';'), videoLabel: '[outv]', audioLabel, inputs };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
export function probeDuration(file) {
|
|
357
|
+
const bin = ffmpegBin();
|
|
358
|
+
const res = spawnSync(bin, ['-hide_banner', '-i', file], { encoding: 'utf8' });
|
|
359
|
+
const m = (res.stderr || '').match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
|
|
360
|
+
if (!m) return null;
|
|
361
|
+
return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
|
|
362
|
+
}
|
package/src/caption.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caption rendering, in the browser.
|
|
3
|
+
*
|
|
4
|
+
* The obvious route is ffmpeg's `drawtext`. It is not available: the bundled
|
|
5
|
+
* `ffmpeg-static` build reports `--enable-libfreetype` but ships no `drawtext` filter, so
|
|
6
|
+
* the only text path is libass — which renders in whatever face fontconfig happens to find
|
|
7
|
+
* on the machine. A caption that is Helvetica on one laptop and DejaVu in CI is not a
|
|
8
|
+
* caption, it is a defect that only shows up in the deliverable.
|
|
9
|
+
*
|
|
10
|
+
* So captions are rendered by Chromium, which the recorder already launches, using the
|
|
11
|
+
* studio's own `tokens.css`. That buys the real typeface, real kerning, and text that
|
|
12
|
+
* matches the pages being recorded — with no font vendored into the repo and no new
|
|
13
|
+
* dependency. Each caption becomes a transparent PNG that ffmpeg overlays.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
|
|
18
|
+
/** Safe-area insets as a fraction of the frame, per aspect. Vertical needs more bottom
|
|
19
|
+
* room because feed UI sits there. */
|
|
20
|
+
const INSET = {
|
|
21
|
+
'16:9': { bottom: 0.09, side: 0.06, maxWidth: 0.62 },
|
|
22
|
+
'9:16': { bottom: 0.16, side: 0.08, maxWidth: 0.86 },
|
|
23
|
+
'1:1': { bottom: 0.11, side: 0.07, maxWidth: 0.8 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function captionHtml({ text, kicker, width, height, aspect, tokensCss }) {
|
|
27
|
+
const inset = INSET[aspect] || INSET['16:9'];
|
|
28
|
+
// Scale type to the frame rather than fixing px, so one caption spec reads correctly at
|
|
29
|
+
// 1920x1080 and at 1080x1920.
|
|
30
|
+
const base = Math.round(height * (aspect === '9:16' ? 0.030 : 0.038));
|
|
31
|
+
|
|
32
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
33
|
+
${tokensCss}
|
|
34
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
35
|
+
html,body{width:${width}px;height:${height}px;background:transparent;overflow:hidden}
|
|
36
|
+
.wrap{
|
|
37
|
+
position:absolute;
|
|
38
|
+
left:${Math.round(width * inset.side)}px;
|
|
39
|
+
bottom:${Math.round(height * inset.bottom)}px;
|
|
40
|
+
max-width:${Math.round(width * inset.maxWidth)}px;
|
|
41
|
+
}
|
|
42
|
+
.kicker{
|
|
43
|
+
font-family:var(--font-mono,ui-monospace,Menlo,monospace);
|
|
44
|
+
font-size:${Math.round(base * 0.42)}px;
|
|
45
|
+
letter-spacing:.22em;
|
|
46
|
+
text-transform:uppercase;
|
|
47
|
+
color:var(--brand-accent,#c9852f);
|
|
48
|
+
margin-bottom:${Math.round(base * 0.42)}px;
|
|
49
|
+
/* A shadow rather than a plate: the caption must not hide the product behind it. */
|
|
50
|
+
text-shadow:0 2px 12px rgba(0,0,0,.85), 0 1px 3px rgba(0,0,0,.95);
|
|
51
|
+
}
|
|
52
|
+
.text{
|
|
53
|
+
font-family:var(--font-display,Georgia,serif);
|
|
54
|
+
font-weight:400;
|
|
55
|
+
font-size:${base}px;
|
|
56
|
+
line-height:1.15;
|
|
57
|
+
color:#fff;
|
|
58
|
+
text-shadow:0 3px 18px rgba(0,0,0,.9), 0 1px 4px rgba(0,0,0,.95);
|
|
59
|
+
text-wrap:balance;
|
|
60
|
+
}
|
|
61
|
+
</style></head><body>
|
|
62
|
+
<div class="wrap">
|
|
63
|
+
${kicker ? `<div class="kicker">${escapeHtml(kicker)}</div>` : ''}
|
|
64
|
+
<div class="text">${escapeHtml(text)}</div>
|
|
65
|
+
</div>
|
|
66
|
+
</body></html>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeHtml(s) {
|
|
70
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
71
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render each caption to a transparent PNG.
|
|
77
|
+
*
|
|
78
|
+
* One browser for the whole set — launching per caption costs more than the rendering.
|
|
79
|
+
*
|
|
80
|
+
* @param {{text:string,kicker?:string,key:string}[]} captions
|
|
81
|
+
* @param {object} opts
|
|
82
|
+
* @returns {Promise<Map<string,string>>} caption key to PNG path
|
|
83
|
+
*/
|
|
84
|
+
export async function renderCaptions(captions, { chromium, launch, dir, width, height, aspect, root }) {
|
|
85
|
+
const out = new Map();
|
|
86
|
+
if (!captions.length) return out;
|
|
87
|
+
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
// Inline the studio tokens so the caption carries the same palette and typefaces as the
|
|
91
|
+
// pages it sits over.
|
|
92
|
+
let tokensCss = '';
|
|
93
|
+
const tokensPath = path.join(root, 'assets', 'lib', 'tokens.css');
|
|
94
|
+
if (fs.existsSync(tokensPath)) tokensCss = fs.readFileSync(tokensPath, 'utf8');
|
|
95
|
+
|
|
96
|
+
const browser = await launch(chromium);
|
|
97
|
+
try {
|
|
98
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
99
|
+
for (const cap of captions) {
|
|
100
|
+
const file = path.join(dir, `${cap.key}.png`);
|
|
101
|
+
await page.setContent(
|
|
102
|
+
captionHtml({ text: cap.text, kicker: cap.kicker, width, height, aspect, tokensCss }),
|
|
103
|
+
{ waitUntil: 'load' }
|
|
104
|
+
);
|
|
105
|
+
await page.evaluate(() => document.fonts?.ready).catch(() => {});
|
|
106
|
+
// omitBackground is what makes the PNG transparent, so the overlay carries only the
|
|
107
|
+
// text rather than a black rectangle over the footage.
|
|
108
|
+
await page.screenshot({ path: file, omitBackground: true });
|
|
109
|
+
out.set(cap.key, file);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
await browser.close().catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame capture over the DevTools screencast.
|
|
3
|
+
*
|
|
4
|
+
* Why not `page.screenshot()` in a loop: a screenshot round-trip costs 50-100ms, which
|
|
5
|
+
* caps you near 12fps and stutters. Why not Playwright's built-in `recordVideo`: it is
|
|
6
|
+
* WebM only, gives no control over quality, and its timing is opaque when you want to
|
|
7
|
+
* re-cut the result.
|
|
8
|
+
*
|
|
9
|
+
* `Page.startScreencast` pushes a frame whenever the compositor paints one, each carrying
|
|
10
|
+
* a real timestamp. That is the important part: frames arrive *irregularly* — a heavy
|
|
11
|
+
* Three.js scene under SwiftShader may deliver 8fps for a second and 40fps the next — so
|
|
12
|
+
* the recording keeps every frame's true timestamp and lets ffmpeg resample to a constant
|
|
13
|
+
* rate at assembly. Treating irregular frames as if they were evenly spaced is what makes
|
|
14
|
+
* naive screen captures of WebGL look like they are speeding up and slowing down.
|
|
15
|
+
*
|
|
16
|
+
* The compositor output includes WebGL and canvas, so 3D content is captured as painted
|
|
17
|
+
* rather than needing any per-canvas hook.
|
|
18
|
+
*
|
|
19
|
+
* A frame that takes 900ms to paint still occupies 900ms of the timeline here. Removing
|
|
20
|
+
* that dependency on machine speed is the deterministic-clock work, not this.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
|
|
25
|
+
export class ScreencastRecorder {
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('playwright-core').Page} page
|
|
28
|
+
* @param {{ dir: string, quality?: number, maxWidth?: number, maxHeight?: number }} opts
|
|
29
|
+
*/
|
|
30
|
+
constructor(page, opts) {
|
|
31
|
+
this.page = page;
|
|
32
|
+
this.dir = opts.dir;
|
|
33
|
+
this.quality = opts.quality ?? 80;
|
|
34
|
+
this.maxWidth = opts.maxWidth;
|
|
35
|
+
this.maxHeight = opts.maxHeight;
|
|
36
|
+
this.frames = [];
|
|
37
|
+
this.session = null;
|
|
38
|
+
this.running = false;
|
|
39
|
+
this.dropped = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start() {
|
|
43
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
44
|
+
this.session = await this.page.context().newCDPSession(this.page);
|
|
45
|
+
|
|
46
|
+
this.session.on('Page.screencastFrame', async (frame) => {
|
|
47
|
+
// Acknowledge first and always: Chromium sends no further frames until the current
|
|
48
|
+
// one is acked, so a throw between here and the ack silently ends the recording.
|
|
49
|
+
const ack = this.session
|
|
50
|
+
.send('Page.screencastFrameAck', { sessionId: frame.sessionId })
|
|
51
|
+
.catch(() => {});
|
|
52
|
+
|
|
53
|
+
if (this.running) {
|
|
54
|
+
const index = this.frames.length;
|
|
55
|
+
const file = path.join(this.dir, `f${String(index).padStart(6, '0')}.jpg`);
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(file, Buffer.from(frame.data, 'base64'));
|
|
58
|
+
this.frames.push({ file, t: frame.metadata.timestamp });
|
|
59
|
+
} catch {
|
|
60
|
+
this.dropped++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await ack;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.running = true;
|
|
67
|
+
await this.session.send('Page.startScreencast', {
|
|
68
|
+
format: 'jpeg',
|
|
69
|
+
quality: this.quality,
|
|
70
|
+
everyNthFrame: 1,
|
|
71
|
+
...(this.maxWidth ? { maxWidth: this.maxWidth } : {}),
|
|
72
|
+
...(this.maxHeight ? { maxHeight: this.maxHeight } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async stop() {
|
|
77
|
+
this.running = false;
|
|
78
|
+
if (!this.session) return this.summary();
|
|
79
|
+
await this.session.send('Page.stopScreencast').catch(() => {});
|
|
80
|
+
await this.session.detach().catch(() => {});
|
|
81
|
+
this.session = null;
|
|
82
|
+
return this.summary();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
summary() {
|
|
86
|
+
const times = this.frames.map((f) => f.t).filter((t) => typeof t === 'number');
|
|
87
|
+
const duration = times.length > 1 ? times[times.length - 1] - times[0] : 0;
|
|
88
|
+
|
|
89
|
+
// Average frame rate over the whole take is not the number that matters, and reading
|
|
90
|
+
// it as if it were understates the capture badly: a deliberate two-second hold on a
|
|
91
|
+
// static page paints nothing, contributes no frames, and drags the mean down. What
|
|
92
|
+
// decides whether motion looks smooth is the rate *while something is moving*, so
|
|
93
|
+
// gaps longer than a fifth of a second are treated as held frames and excluded.
|
|
94
|
+
const gaps = [];
|
|
95
|
+
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
|
96
|
+
const moving = gaps.filter((g) => g < 0.2);
|
|
97
|
+
const movingTime = moving.reduce((a, b) => a + b, 0);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
count: this.frames.length,
|
|
101
|
+
dropped: this.dropped,
|
|
102
|
+
durationSec: duration,
|
|
103
|
+
fps: duration > 0 ? this.frames.length / duration : 0,
|
|
104
|
+
motionFps: movingTime > 0 ? moving.length / movingTime : 0,
|
|
105
|
+
heldFrames: gaps.length - moving.length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ffmpeg concat manifest carrying each frame's real on-screen duration. Assembly
|
|
111
|
+
* resamples this to a constant rate, which is what turns irregular capture into
|
|
112
|
+
* even playback.
|
|
113
|
+
*/
|
|
114
|
+
writeManifest(file) {
|
|
115
|
+
return writeManifest(this.frames, file);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ffmpeg concat manifest carrying each frame's on-screen duration.
|
|
121
|
+
*
|
|
122
|
+
* Shared by both capture modes on purpose. Under realtime capture the durations vary,
|
|
123
|
+
* because the compositor paints when it can, and the encode resamples them to a constant
|
|
124
|
+
* rate. Under deterministic capture they are all identical and the resample is a no-op —
|
|
125
|
+
* which is the entire point of that mode.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ file: string, t: number }[]} frames
|
|
128
|
+
* @param {string} file manifest path
|
|
129
|
+
*/
|
|
130
|
+
export function writeManifest(frames, file) {
|
|
131
|
+
if (!frames || frames.length < 2) throw new Error('nothing captured — no frames to assemble');
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (let i = 0; i < frames.length; i++) {
|
|
134
|
+
const next = frames[i + 1];
|
|
135
|
+
lines.push(`file '${path.basename(frames[i].file)}'`);
|
|
136
|
+
const dur = next ? Math.max(0.001, next.t - frames[i].t) : 0.04;
|
|
137
|
+
lines.push(`duration ${dur.toFixed(6)}`);
|
|
138
|
+
}
|
|
139
|
+
// The concat demuxer ignores the final duration unless the last file is repeated.
|
|
140
|
+
lines.push(`file '${path.basename(frames[frames.length - 1].file)}'`);
|
|
141
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
142
|
+
return file;
|
|
143
|
+
}
|