@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/edit.mjs
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit decision lists — deriving a short cut from a recorded master.
|
|
3
|
+
*
|
|
4
|
+
* A social clip is not a different recording, it is a different *reading* of one. Cutting
|
|
5
|
+
* from the master rather than re-recording keeps the two consistent, costs seconds instead
|
|
6
|
+
* of minutes, and means the 30-second version cannot drift from the two-minute version it
|
|
7
|
+
* claims to summarise.
|
|
8
|
+
*
|
|
9
|
+
* Segments address the master by shot id, resolved against the timeline in `capture.json`,
|
|
10
|
+
* so an edit survives the script being re-timed: shots move, the edit still points at the
|
|
11
|
+
* right material. Explicit `from`/`to` seconds are available where a shot needs trimming
|
|
12
|
+
* inside itself.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
import { validateMotion } from './motion.mjs';
|
|
18
|
+
|
|
19
|
+
export const ASPECTS = {
|
|
20
|
+
'16:9': { w: 1920, h: 1080 },
|
|
21
|
+
'9:16': { w: 1080, h: 1920 },
|
|
22
|
+
'1:1': { w: 1080, h: 1080 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Transitions, named for what a director would call them and mapped to the ffmpeg filter
|
|
27
|
+
* that implements each.
|
|
28
|
+
*
|
|
29
|
+
* A transition belongs to the segment it moves *into* — "dissolve into the WebGL shot" —
|
|
30
|
+
* which is how an edit reads aloud and why segment 0 cannot carry one: there is nothing
|
|
31
|
+
* to come from. `cut` is the absence of a transition and stays the default, because a
|
|
32
|
+
* demo that dissolves between every shot reads as a screensaver.
|
|
33
|
+
*
|
|
34
|
+
* Every transition costs runtime. Two shots joined by a 0.5s dissolve occupy 0.5s less
|
|
35
|
+
* than their sum, since the tail of one plays over the head of the next — which is why
|
|
36
|
+
* `resolve` subtracts them rather than reporting a total the file will not match.
|
|
37
|
+
*/
|
|
38
|
+
export const TRANSITIONS = {
|
|
39
|
+
cut: null,
|
|
40
|
+
fade: 'fade',
|
|
41
|
+
// A director saying "dissolve" means a crossfade. ffmpeg's filter of that name is a
|
|
42
|
+
// *grain* dissolve — pixels swapped at random, which on a dark UI reads as static over
|
|
43
|
+
// the shot. Found by extracting a frame from the middle of one rather than by reading
|
|
44
|
+
// the docs. So `dissolve` maps to the smooth blend people mean, and the grainy one is
|
|
45
|
+
// available under a name that describes it.
|
|
46
|
+
dissolve: 'fade',
|
|
47
|
+
grain: 'dissolve',
|
|
48
|
+
fadeblack: 'fadeblack',
|
|
49
|
+
fadewhite: 'fadewhite',
|
|
50
|
+
wipeleft: 'wipeleft',
|
|
51
|
+
wiperight: 'wiperight',
|
|
52
|
+
slideleft: 'slideleft',
|
|
53
|
+
slideright: 'slideright',
|
|
54
|
+
slideup: 'slideup',
|
|
55
|
+
slidedown: 'slidedown',
|
|
56
|
+
smoothleft: 'smoothleft',
|
|
57
|
+
smoothright: 'smoothright',
|
|
58
|
+
circleopen: 'circleopen',
|
|
59
|
+
circleclose: 'circleclose',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const DEFAULT_TRANSITION_SEC = 0.4;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `"transition": "dissolve"` or `{ "type": "dissolve", "durationSec": 0.6 }`.
|
|
66
|
+
* Both forms appear in real edit files; the shorthand is what gets written by hand.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeTransition(value) {
|
|
69
|
+
if (value == null) return { type: 'cut', durationSec: 0 };
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
return { type: value, durationSec: value === 'cut' ? 0 : DEFAULT_TRANSITION_SEC };
|
|
72
|
+
}
|
|
73
|
+
if (typeof value !== 'object') return null;
|
|
74
|
+
const type = value.type ?? 'cut';
|
|
75
|
+
return {
|
|
76
|
+
type,
|
|
77
|
+
durationSec: type === 'cut' ? 0 : Number(value.durationSec ?? DEFAULT_TRANSITION_SEC),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Static validation. Returns a list of problems, empty when the edit is well formed.
|
|
83
|
+
* Timeline-dependent checks live in `resolve`, which needs the capture to compare against.
|
|
84
|
+
*/
|
|
85
|
+
export function validateEdit(edit) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
if (!edit || typeof edit !== 'object') return ['edit is not an object'];
|
|
88
|
+
if (!edit.name || !/^[a-z0-9][a-z0-9-]*$/.test(edit.name)) {
|
|
89
|
+
errors.push('name must be a lowercase slug — it becomes the output filename');
|
|
90
|
+
}
|
|
91
|
+
if (!edit.source) errors.push('missing source — the recording this cuts from');
|
|
92
|
+
if (edit.aspect && !ASPECTS[edit.aspect]) {
|
|
93
|
+
errors.push(`unknown aspect "${edit.aspect}" — one of ${Object.keys(ASPECTS).join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(edit.segments) || edit.segments.length === 0) {
|
|
96
|
+
errors.push('edit has no segments');
|
|
97
|
+
return errors;
|
|
98
|
+
}
|
|
99
|
+
edit.segments.forEach((seg, i) => {
|
|
100
|
+
const at = `segment ${i}`;
|
|
101
|
+
if (!seg || typeof seg !== 'object') return errors.push(`${at}: not an object`);
|
|
102
|
+
if (!seg.shot && seg.from == null) errors.push(`${at}: needs a shot id or an explicit from`);
|
|
103
|
+
if (seg.from != null && seg.to != null && seg.to <= seg.from) {
|
|
104
|
+
errors.push(`${at}: to (${seg.to}) is not after from (${seg.from})`);
|
|
105
|
+
}
|
|
106
|
+
if (seg.speed != null && (seg.speed < 0.25 || seg.speed > 4)) {
|
|
107
|
+
errors.push(`${at}: speed ${seg.speed} is outside 0.25-4`);
|
|
108
|
+
}
|
|
109
|
+
if (seg.focusX != null && (seg.focusX < 0 || seg.focusX > 1)) {
|
|
110
|
+
errors.push(`${at}: focusX ${seg.focusX} is outside 0-1`);
|
|
111
|
+
}
|
|
112
|
+
if (seg.caption != null && typeof seg.caption !== 'string') {
|
|
113
|
+
errors.push(`${at}: caption must be a string`);
|
|
114
|
+
}
|
|
115
|
+
if (seg.fit != null && !['cover', 'contain'].includes(seg.fit)) {
|
|
116
|
+
errors.push(`${at}: fit "${seg.fit}" — use "cover" (crop to fill) or "contain" (letterbox)`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tr = normalizeTransition(seg.transition);
|
|
120
|
+
if (tr === null) {
|
|
121
|
+
errors.push(`${at}: transition must be a name or { type, durationSec }`);
|
|
122
|
+
} else if (!(tr.type in TRANSITIONS)) {
|
|
123
|
+
errors.push(`${at}: unknown transition "${tr.type}" — one of ${Object.keys(TRANSITIONS).join(', ')}`);
|
|
124
|
+
} else if (tr.type !== 'cut') {
|
|
125
|
+
if (i === 0) {
|
|
126
|
+
errors.push(`${at}: the first segment cannot have a transition — there is nothing to come from`);
|
|
127
|
+
}
|
|
128
|
+
if (!(tr.durationSec > 0) || tr.durationSec > 3) {
|
|
129
|
+
errors.push(`${at}: transition durationSec ${tr.durationSec} is outside 0-3`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (seg.captionAt != null && seg.captionAt < 0) {
|
|
134
|
+
errors.push(`${at}: captionAt ${seg.captionAt} is before the segment starts`);
|
|
135
|
+
}
|
|
136
|
+
if (seg.captionForSec != null && !(seg.captionForSec > 0)) {
|
|
137
|
+
errors.push(`${at}: captionForSec must be positive`);
|
|
138
|
+
}
|
|
139
|
+
if (seg.captionFadeSec != null && (seg.captionFadeSec < 0 || seg.captionFadeSec > 2)) {
|
|
140
|
+
errors.push(`${at}: captionFadeSec ${seg.captionFadeSec} is outside 0-2`);
|
|
141
|
+
}
|
|
142
|
+
if (seg.motion != null) {
|
|
143
|
+
if (!seg.caption && !seg.kicker) {
|
|
144
|
+
errors.push(`${at}: motion needs a caption or kicker to animate`);
|
|
145
|
+
}
|
|
146
|
+
errors.push(...validateMotion(seg.motion, `${at} motion`));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Audio is a whole-cut property: one bed under the piece, not a track per segment. Per
|
|
151
|
+
// segment audio would need mixing rules nobody has asked for, and a music bed that
|
|
152
|
+
// restarts at every cut is the sound of an edit fighting itself.
|
|
153
|
+
if (edit.audio != null) {
|
|
154
|
+
const a = edit.audio;
|
|
155
|
+
if (typeof a !== 'object') {
|
|
156
|
+
errors.push('audio must be an object — { file, gainDb, fadeInSec, fadeOutSec }');
|
|
157
|
+
} else {
|
|
158
|
+
if (!a.file || typeof a.file !== 'string') errors.push('audio.file is required');
|
|
159
|
+
if (a.gainDb != null && (a.gainDb < -60 || a.gainDb > 12)) {
|
|
160
|
+
errors.push(`audio.gainDb ${a.gainDb} is outside -60 to 12`);
|
|
161
|
+
}
|
|
162
|
+
for (const key of ['fadeInSec', 'fadeOutSec']) {
|
|
163
|
+
if (a[key] != null && (a[key] < 0 || a[key] > 10)) {
|
|
164
|
+
errors.push(`audio.${key} ${a[key]} is outside 0-10`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return errors;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Turn segments into absolute in/out points against a capture.
|
|
175
|
+
*
|
|
176
|
+
* The failure this exists to catch is an edit that still names a shot the script has since
|
|
177
|
+
* renamed or removed. Nothing about that is visible in the edit file, and ffmpeg would
|
|
178
|
+
* happily cut from second zero — producing a plausible clip of the wrong material.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} edit
|
|
181
|
+
* @param {object} capture parsed capture.json
|
|
182
|
+
*/
|
|
183
|
+
export function resolve(edit, capture) {
|
|
184
|
+
const problems = [];
|
|
185
|
+
const timeline = capture.timeline || [];
|
|
186
|
+
const byId = new Map(timeline.map((s) => [s.id, s]));
|
|
187
|
+
const duration = capture.durationSec ?? (timeline.length ? timeline[timeline.length - 1].endSec : 0);
|
|
188
|
+
const segments = [];
|
|
189
|
+
|
|
190
|
+
edit.segments.forEach((seg, i) => {
|
|
191
|
+
const at = `segment ${i}${seg.shot ? ` (${seg.shot})` : ''}`;
|
|
192
|
+
let start;
|
|
193
|
+
let end;
|
|
194
|
+
|
|
195
|
+
if (seg.shot) {
|
|
196
|
+
const shot = byId.get(seg.shot);
|
|
197
|
+
if (!shot) {
|
|
198
|
+
problems.push(`${at}: no shot "${seg.shot}" in ${capture.name} — it has ${[...byId.keys()].join(', ')}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// from/to alongside a shot are offsets *within* it, which is how a director thinks
|
|
202
|
+
// about trimming: "the last two seconds of the orbit", not "second 19.6 of the file".
|
|
203
|
+
start = shot.startSec + (seg.from ?? 0);
|
|
204
|
+
end = seg.to != null ? shot.startSec + seg.to : shot.endSec;
|
|
205
|
+
if (end > shot.endSec + 1e-6) {
|
|
206
|
+
problems.push(`${at}: to ${seg.to}s runs past the shot, which is ${(shot.endSec - shot.startSec).toFixed(2)}s long`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
start = seg.from;
|
|
211
|
+
end = seg.to ?? duration;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (start < 0 || end > duration + 1e-6) {
|
|
215
|
+
problems.push(`${at}: ${start.toFixed(2)}-${end.toFixed(2)}s falls outside the ${duration.toFixed(2)}s master`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (end - start < 0.25) {
|
|
219
|
+
problems.push(`${at}: ${(end - start).toFixed(2)}s is too short to register`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const speed = seg.speed ?? 1;
|
|
224
|
+
const transition = normalizeTransition(seg.transition) ?? { type: 'cut', durationSec: 0 };
|
|
225
|
+
segments.push({
|
|
226
|
+
index: i,
|
|
227
|
+
transition,
|
|
228
|
+
shot: seg.shot ?? null,
|
|
229
|
+
start,
|
|
230
|
+
end,
|
|
231
|
+
speed,
|
|
232
|
+
// Per segment, falling back to the edit default. One focus for a whole cut is wrong
|
|
233
|
+
// as soon as two shots put their subject in different places: cropping the studio
|
|
234
|
+
// tour to 9:16 with a single value framed the 3D stage correctly and sliced the left
|
|
235
|
+
// column off the brief form.
|
|
236
|
+
focusX: seg.focusX ?? edit.focusX ?? 0.5,
|
|
237
|
+
sourceSec: end - start,
|
|
238
|
+
outSec: (end - start) / speed,
|
|
239
|
+
fit: seg.fit ?? edit.fit ?? 'cover',
|
|
240
|
+
caption: seg.caption ?? null,
|
|
241
|
+
kicker: seg.kicker ?? null,
|
|
242
|
+
captionAt: seg.captionAt ?? 0,
|
|
243
|
+
captionForSec: seg.captionForSec ?? null,
|
|
244
|
+
captionFadeSec: seg.captionFadeSec ?? edit.captionFadeSec ?? 0,
|
|
245
|
+
motion: seg.motion ?? null,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// A transition overlaps its two segments, so the piece is shorter than the sum of its
|
|
250
|
+
// parts by exactly the transition durations. Getting this wrong is not cosmetic: the CLI
|
|
251
|
+
// compares the encoded duration against this number to catch a segment silently dropped
|
|
252
|
+
// by a mislabelled filtergraph, and an total that ignores overlap would fail every edit
|
|
253
|
+
// that dissolves.
|
|
254
|
+
let overlapSec = 0;
|
|
255
|
+
segments.forEach((s, i) => {
|
|
256
|
+
if (i === 0 || s.transition.type === 'cut') return;
|
|
257
|
+
// A transition cannot be longer than either shot it joins — ffmpeg produces a valid
|
|
258
|
+
// file with the tail of the shorter one missing, which is a silent content change.
|
|
259
|
+
const shorter = Math.min(s.outSec, segments[i - 1].outSec);
|
|
260
|
+
if (s.transition.durationSec >= shorter) {
|
|
261
|
+
problems.push(
|
|
262
|
+
`segment ${s.index}${s.shot ? ` (${s.shot})` : ''}: a ${s.transition.durationSec}s `
|
|
263
|
+
+ `${s.transition.type} does not fit between shots of ${segments[i - 1].outSec.toFixed(2)}s `
|
|
264
|
+
+ `and ${s.outSec.toFixed(2)}s — it would consume one of them whole`
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
overlapSec += s.transition.durationSec;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const totalSec = segments.reduce((a, s) => a + s.outSec, 0) - overlapSec;
|
|
272
|
+
return { segments, problems, totalSec, overlapSec, sourceDurationSec: duration };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Advice on a duration budget. Reported rather than enforced: silently speeding a cut up
|
|
277
|
+
* to hit a number changes the pacing the director chose, and pacing is the whole point of
|
|
278
|
+
* the format.
|
|
279
|
+
*/
|
|
280
|
+
export function budgetReport(totalSec, budgetSec) {
|
|
281
|
+
if (!budgetSec) return null;
|
|
282
|
+
const over = totalSec - budgetSec;
|
|
283
|
+
if (over <= 0.05) return { ok: true, totalSec, budgetSec };
|
|
284
|
+
const speed = totalSec / budgetSec;
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
totalSec,
|
|
288
|
+
budgetSec,
|
|
289
|
+
overSec: over,
|
|
290
|
+
suggestedSpeed: Number(speed.toFixed(2)),
|
|
291
|
+
hint: `trim ${over.toFixed(1)}s, or set "speed": ${speed.toFixed(2)} across the segments`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function loadCapture(root, sourceName) {
|
|
296
|
+
const file = path.join(root, 'recordings', sourceName, 'capture.json');
|
|
297
|
+
if (!fs.existsSync(file)) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`no capture for "${sourceName}" — run \`npm run record -- recordings/${sourceName}/script.json\` first`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
303
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downsampled luminance grids for frame comparison.
|
|
3
|
+
*
|
|
4
|
+
* The critic first compared frames by their luminance *aggregates* — mean, spread,
|
|
5
|
+
* occupancy — which `imageStats` already provides. That is unusably weak for video: a
|
|
6
|
+
* pure translation leaves every aggregate unchanged, so a scrolling repeated pattern, or
|
|
7
|
+
* a 3D object rotating under constant light, measured as perfectly still. The critic
|
|
8
|
+
* would then report a moving shot as frozen, which is the exact failure it exists to
|
|
9
|
+
* catch, inverted.
|
|
10
|
+
*
|
|
11
|
+
* Comparing a small luminance grid instead sees position. 16x16 is enough to register a
|
|
12
|
+
* scroll or an orbit and coarse enough to ignore compression noise, which a pixel-exact
|
|
13
|
+
* comparison would report as constant motion.
|
|
14
|
+
*
|
|
15
|
+
* Every frame is read in a single Python call. Per-frame spawning cost more than the
|
|
16
|
+
* decode for a strip of any length.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
|
|
20
|
+
export const GRID = 16;
|
|
21
|
+
|
|
22
|
+
const PY = `
|
|
23
|
+
import sys, json
|
|
24
|
+
from PIL import Image
|
|
25
|
+
paths = json.loads(sys.stdin.read())
|
|
26
|
+
out = []
|
|
27
|
+
for p in paths:
|
|
28
|
+
try:
|
|
29
|
+
im = Image.open(p).convert('L').resize((${GRID}, ${GRID}), Image.BILINEAR)
|
|
30
|
+
out.append(list(im.getdata()))
|
|
31
|
+
except Exception:
|
|
32
|
+
out.append(None)
|
|
33
|
+
print(json.dumps(out))
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} files
|
|
38
|
+
* @returns {{ ok: boolean, grids: (number[]|null)[], error?: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function frameGrids(files) {
|
|
41
|
+
if (!files.length) return { ok: true, grids: [] };
|
|
42
|
+
|
|
43
|
+
for (const bin of ['python3', 'python']) {
|
|
44
|
+
const res = spawnSync(bin, ['-c', PY], {
|
|
45
|
+
input: JSON.stringify(files),
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
48
|
+
});
|
|
49
|
+
if (res.error || res.status !== 0) continue;
|
|
50
|
+
try {
|
|
51
|
+
const grids = JSON.parse(res.stdout);
|
|
52
|
+
if (Array.isArray(grids) && grids.length === files.length) return { ok: true, grids };
|
|
53
|
+
} catch {
|
|
54
|
+
// fall through to the next interpreter
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No backend means no measurement. Saying so is the only honest option — an unmeasured
|
|
59
|
+
// critique that reports a score is worse than one that refuses.
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
grids: files.map(() => null),
|
|
63
|
+
error: 'No pixel backend for frame comparison — python3 with Pillow is required',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Mean absolute luminance difference between two grids, 0-255. */
|
|
68
|
+
export function gridDelta(a, b) {
|
|
69
|
+
if (!a || !b || a.length !== b.length) return null;
|
|
70
|
+
let sum = 0;
|
|
71
|
+
for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
|
|
72
|
+
return sum / a.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Luminance spread within one grid — near zero means a flat, illegible frame. */
|
|
76
|
+
export function gridSpread(g) {
|
|
77
|
+
if (!g || !g.length) return null;
|
|
78
|
+
const mean = g.reduce((a, b) => a + b, 0) / g.length;
|
|
79
|
+
return Math.sqrt(g.reduce((a, v) => a + (v - mean) ** 2, 0) / g.length);
|
|
80
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reel — a browser, driven from a script, recorded.
|
|
3
|
+
*
|
|
4
|
+
* The script is data, not code: validate it before spending four minutes on a
|
|
5
|
+
* capture, resolve every selector in a dry run, and record with a fixed clock
|
|
6
|
+
* so the frames do not depend on how long a frame cost to draw.
|
|
7
|
+
*/
|
|
8
|
+
export { validate, selectorsFor, dryRun, parseViewport } from './schema.mjs';
|
|
9
|
+
export { ACTION_TYPES, ACTIONS, resetJitter } from './actions.mjs';
|
|
10
|
+
export { resolveExecutable, launch, record } from './runner.mjs';
|
|
11
|
+
export {
|
|
12
|
+
encodeMaster, encodeVertical, encodeGif,
|
|
13
|
+
sampleFrames, extractFrames, cutEdit, buildEditGraph, probeDuration,
|
|
14
|
+
} from './assemble.mjs';
|
|
15
|
+
export {
|
|
16
|
+
ASPECTS, TRANSITIONS, normalizeTransition,
|
|
17
|
+
validateEdit, resolve as resolveEdit, budgetReport, loadCapture,
|
|
18
|
+
} from './edit.mjs';
|
|
19
|
+
export { critique, writeCritique, CRITIC_WEIGHTS } from './critic.mjs';
|
package/src/motion.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion graphics — animated type, rendered a frame at a time in Chromium.
|
|
3
|
+
*
|
|
4
|
+
* Captions burned in as a single still can fade and can be scheduled, and that is the
|
|
5
|
+
* ceiling: the plate is the same image for every frame it appears in. Type that moves —
|
|
6
|
+
* a line rising into place, a rule drawing itself under a kicker, words arriving one after
|
|
7
|
+
* another — is what separates a demo from a screen recording with subtitles, and it is the
|
|
8
|
+
* one thing a still overlay can never do.
|
|
9
|
+
*
|
|
10
|
+
* ffmpeg cannot help here. Its text filter is absent from the bundled build (see
|
|
11
|
+
* caption.mjs), and even with it, keyframed type would mean expressing a design in filter
|
|
12
|
+
* expressions. So the same machine that renders the captions renders the animation: a page
|
|
13
|
+
* in Chromium, in the studio's own typefaces, screenshotted once per frame onto a
|
|
14
|
+
* transparent background. The result is an image sequence ffmpeg overlays like any other.
|
|
15
|
+
*
|
|
16
|
+
* The animation is evaluated in Node and applied as inline styles per frame — deliberately
|
|
17
|
+
* not CSS animations or WAAPI. A CSS animation advances on the browser's own clock, so the
|
|
18
|
+
* frame you screenshot is whatever the compositor had reached when the call returned, and
|
|
19
|
+
* two runs of the same edit would differ. Computing the state for frame `i` makes the
|
|
20
|
+
* sequence a pure function of the spec: byte-identical across runs and machines, which is
|
|
21
|
+
* the property the deterministic capture mode exists to protect.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
|
|
26
|
+
/** Presets, named for what they do to the eye rather than for the property they animate. */
|
|
27
|
+
export const MOTION_PRESETS = {
|
|
28
|
+
/** Rises into place from below, fading in. The workhorse for a lower third. */
|
|
29
|
+
rise: { dy: 0.045, blur: 0, stagger: 0.10 },
|
|
30
|
+
/** Straight fade, no movement — for a caption that must not pull focus. */
|
|
31
|
+
fade: { dy: 0, blur: 0, stagger: 0.06 },
|
|
32
|
+
/** Drops in from above, for a title that lands on a beat. */
|
|
33
|
+
fall: { dy: -0.045, blur: 0, stagger: 0.10 },
|
|
34
|
+
/** Rises and resolves out of blur, like a rack focus onto the words. */
|
|
35
|
+
focus: { dy: 0.02, blur: 10, stagger: 0.12 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const EXITS = ['fade', 'rise', 'fall', 'hold'];
|
|
39
|
+
|
|
40
|
+
/** easeOutCubic — fast arrival, soft landing. */
|
|
41
|
+
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
|
42
|
+
/** easeInCubic, for exits, so a departure accelerates away. */
|
|
43
|
+
const easeIn = (t) => t * t * t;
|
|
44
|
+
|
|
45
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* State of one animated line at time `t`, in seconds from the start of the overlay.
|
|
49
|
+
*
|
|
50
|
+
* Exported and pure so the curve can be asserted without a browser. Every value it
|
|
51
|
+
* returns is a style the renderer applies verbatim.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {number} opts.t seconds since the overlay began
|
|
55
|
+
* @param {number} opts.index which line this is, for stagger
|
|
56
|
+
* @param {number} opts.durationSec how long the overlay is on screen in total
|
|
57
|
+
* @param {string} opts.preset key of MOTION_PRESETS
|
|
58
|
+
* @param {string} opts.exit one of EXITS
|
|
59
|
+
* @param {number} [opts.enterSec] how long the entrance takes
|
|
60
|
+
* @param {number} [opts.exitSec] how long the exit takes
|
|
61
|
+
*/
|
|
62
|
+
export function lineStateAt({ t, index, durationSec, preset = 'rise', exit = 'fade', enterSec = 0.55, exitSec = 0.4 }) {
|
|
63
|
+
const p = MOTION_PRESETS[preset] || MOTION_PRESETS.rise;
|
|
64
|
+
const delay = index * p.stagger;
|
|
65
|
+
|
|
66
|
+
// Entrance
|
|
67
|
+
const enterT = clamp01((t - delay) / Math.max(0.001, enterSec));
|
|
68
|
+
const inProgress = easeOut(enterT);
|
|
69
|
+
|
|
70
|
+
// Exit. `hold` means the overlay never leaves under its own steam — the segment ending
|
|
71
|
+
// is what removes it, which is what a title card on the final shot wants.
|
|
72
|
+
let outProgress = 0;
|
|
73
|
+
if (exit !== 'hold' && exitSec > 0) {
|
|
74
|
+
const exitStart = durationSec - exitSec;
|
|
75
|
+
outProgress = easeIn(clamp01((t - exitStart) / Math.max(0.001, exitSec)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const opacity = clamp01(inProgress * (1 - outProgress));
|
|
79
|
+
|
|
80
|
+
// Offsets are fractions of frame height, so one spec reads the same at 1080p and in 9:16.
|
|
81
|
+
let offset = p.dy * (1 - inProgress);
|
|
82
|
+
if (exit === 'rise') offset -= p.dy * outProgress;
|
|
83
|
+
if (exit === 'fall') offset += p.dy * outProgress;
|
|
84
|
+
|
|
85
|
+
const blur = p.blur * (1 - inProgress);
|
|
86
|
+
|
|
87
|
+
return { opacity, offsetY: offset, blurPx: blur };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a motion spec. Returns problems; empty means well formed.
|
|
92
|
+
*/
|
|
93
|
+
export function validateMotion(motion, at = 'motion') {
|
|
94
|
+
const errors = [];
|
|
95
|
+
if (motion == null) return errors;
|
|
96
|
+
if (typeof motion !== 'object') return [`${at}: must be an object`];
|
|
97
|
+
|
|
98
|
+
if (motion.preset != null && !(motion.preset in MOTION_PRESETS)) {
|
|
99
|
+
errors.push(`${at}: unknown preset "${motion.preset}" — one of ${Object.keys(MOTION_PRESETS).join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (motion.exit != null && !EXITS.includes(motion.exit)) {
|
|
102
|
+
errors.push(`${at}: unknown exit "${motion.exit}" — one of ${EXITS.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
for (const key of ['enterSec', 'exitSec']) {
|
|
105
|
+
if (motion[key] != null && (motion[key] < 0 || motion[key] > 4)) {
|
|
106
|
+
errors.push(`${at}: ${key} ${motion[key]} is outside 0-4`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// An entrance and an exit that together outlast the overlay means the line never
|
|
110
|
+
// reaches full opacity — it fades up straight into fading down, which reads as a flicker
|
|
111
|
+
// rather than as a title.
|
|
112
|
+
if (motion.durationSec != null) {
|
|
113
|
+
const enter = motion.enterSec ?? 0.55;
|
|
114
|
+
const exitSec = motion.exit === 'hold' ? 0 : (motion.exitSec ?? 0.4);
|
|
115
|
+
if (enter + exitSec > motion.durationSec) {
|
|
116
|
+
errors.push(
|
|
117
|
+
`${at}: enter (${enter}s) + exit (${exitSec}s) exceeds the ${motion.durationSec}s it is on screen — `
|
|
118
|
+
+ 'the type would never settle'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return errors;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function overlayHtml({ lines, width, height, aspect, tokensCss }) {
|
|
126
|
+
const inset = aspect === '9:16'
|
|
127
|
+
? { bottom: 0.16, side: 0.08, maxWidth: 0.86 }
|
|
128
|
+
: aspect === '1:1'
|
|
129
|
+
? { bottom: 0.11, side: 0.07, maxWidth: 0.8 }
|
|
130
|
+
: { bottom: 0.09, side: 0.06, maxWidth: 0.62 };
|
|
131
|
+
const base = Math.round(height * (aspect === '9:16' ? 0.030 : 0.038));
|
|
132
|
+
|
|
133
|
+
const rows = lines.map((l, i) => `
|
|
134
|
+
<div class="line ${l.kind}" data-i="${i}">${escapeHtml(l.text)}</div>`).join('');
|
|
135
|
+
|
|
136
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
137
|
+
${tokensCss}
|
|
138
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
139
|
+
html,body{width:${width}px;height:${height}px;background:transparent;overflow:hidden}
|
|
140
|
+
.wrap{position:absolute;left:${Math.round(width * inset.side)}px;bottom:${Math.round(height * inset.bottom)}px;max-width:${Math.round(width * inset.maxWidth)}px}
|
|
141
|
+
.line{will-change:transform,opacity,filter}
|
|
142
|
+
.kicker{
|
|
143
|
+
font-family:var(--font-mono,ui-monospace,Menlo,monospace);
|
|
144
|
+
font-size:${Math.round(base * 0.42)}px;letter-spacing:.22em;text-transform:uppercase;
|
|
145
|
+
color:var(--brand-accent,#c9852f);margin-bottom:${Math.round(base * 0.42)}px;
|
|
146
|
+
text-shadow:0 2px 12px rgba(0,0,0,.85),0 1px 3px rgba(0,0,0,.95);
|
|
147
|
+
}
|
|
148
|
+
.text{
|
|
149
|
+
font-family:var(--font-display,Georgia,serif);font-weight:400;font-size:${base}px;
|
|
150
|
+
line-height:1.15;color:#fff;text-wrap:balance;
|
|
151
|
+
text-shadow:0 3px 18px rgba(0,0,0,.9),0 1px 4px rgba(0,0,0,.95);
|
|
152
|
+
}
|
|
153
|
+
</style></head><body>
|
|
154
|
+
<div class="wrap">${rows}</div>
|
|
155
|
+
<script>
|
|
156
|
+
// Applied from outside, once per frame. No CSS animation and no requestAnimationFrame:
|
|
157
|
+
// the renderer decides what frame this is, so the page must not have a clock of its own.
|
|
158
|
+
window.__apply = (states) => {
|
|
159
|
+
const els = document.querySelectorAll('.line');
|
|
160
|
+
states.forEach((s, i) => {
|
|
161
|
+
const el = els[i];
|
|
162
|
+
if (!el) return;
|
|
163
|
+
el.style.opacity = String(s.opacity);
|
|
164
|
+
el.style.transform = 'translateY(' + (s.offsetY * ${height}) + 'px)';
|
|
165
|
+
el.style.filter = s.blurPx > 0.01 ? 'blur(' + s.blurPx.toFixed(2) + 'px)' : 'none';
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
</script>
|
|
169
|
+
</body></html>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function escapeHtml(s) {
|
|
173
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
174
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
175
|
+
));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render an animated overlay to a numbered PNG sequence.
|
|
180
|
+
*
|
|
181
|
+
* @returns {Promise<{dir:string, count:number, fps:number}>}
|
|
182
|
+
*/
|
|
183
|
+
export async function renderMotionSequence(spec, { chromium, launch, dir, width, height, aspect, fps, root }) {
|
|
184
|
+
const lines = [];
|
|
185
|
+
if (spec.kicker) lines.push({ kind: 'kicker', text: spec.kicker });
|
|
186
|
+
if (spec.text) lines.push({ kind: 'text', text: spec.text });
|
|
187
|
+
if (!lines.length) return { dir, count: 0, fps };
|
|
188
|
+
|
|
189
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
191
|
+
|
|
192
|
+
let tokensCss = '';
|
|
193
|
+
const tokensPath = path.join(root, 'assets', 'lib', 'tokens.css');
|
|
194
|
+
if (fs.existsSync(tokensPath)) tokensCss = fs.readFileSync(tokensPath, 'utf8');
|
|
195
|
+
|
|
196
|
+
const durationSec = spec.durationSec;
|
|
197
|
+
const frames = Math.max(1, Math.round(durationSec * fps));
|
|
198
|
+
|
|
199
|
+
const browser = await launch(chromium);
|
|
200
|
+
let count = 0;
|
|
201
|
+
try {
|
|
202
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
203
|
+
await page.setContent(overlayHtml({ lines, width, height, aspect, tokensCss }), { waitUntil: 'load' });
|
|
204
|
+
await page.evaluate(() => document.fonts?.ready).catch(() => {});
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < frames; i++) {
|
|
207
|
+
const t = i / fps;
|
|
208
|
+
const states = lines.map((_, index) => lineStateAt({
|
|
209
|
+
t,
|
|
210
|
+
index,
|
|
211
|
+
durationSec,
|
|
212
|
+
preset: spec.preset,
|
|
213
|
+
exit: spec.exit,
|
|
214
|
+
enterSec: spec.enterSec,
|
|
215
|
+
exitSec: spec.exitSec,
|
|
216
|
+
}));
|
|
217
|
+
await page.evaluate((s) => window.__apply(s), states);
|
|
218
|
+
await page.screenshot({
|
|
219
|
+
path: path.join(dir, `m-${String(i + 1).padStart(5, '0')}.png`),
|
|
220
|
+
omitBackground: true,
|
|
221
|
+
});
|
|
222
|
+
count++;
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
await browser.close().catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { dir, count, fps };
|
|
229
|
+
}
|