@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/critic.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame critic — judge a recording from what it actually shows.
|
|
3
|
+
*
|
|
4
|
+
* The plan for this phase said "point design-eval's still-image rubric at the sampled
|
|
5
|
+
* frames". Reading that rubric closely, most of it does not transfer: its axes are
|
|
6
|
+
* `subjectFirst`, `framing`, `geometryCredibility` and `materials`, which are
|
|
7
|
+
* product-photography measures. A frame of someone filling in a form has no subject on a
|
|
8
|
+
* background and no material response, so those axes would score noise and the noise
|
|
9
|
+
* would carry a calibrated-looking number. What does transfer is the *shape* of that
|
|
10
|
+
* rubric — measured axes, blockers separated from warnings, a skip status that
|
|
11
|
+
* renormalises rather than scoring a default — and `imageStats`, whose raw luminance and
|
|
12
|
+
* occupancy measures are subject-agnostic.
|
|
13
|
+
*
|
|
14
|
+
* So the axes here are about what a recording can get wrong, and every one is measured
|
|
15
|
+
* from frames rather than asserted about the script:
|
|
16
|
+
*
|
|
17
|
+
* deadFrames a shot rendered nothing — blank, or a uniform wash
|
|
18
|
+
* frozenMotion consecutive frames identical while the shot claims movement. This is
|
|
19
|
+
* the one that catches a WebGL scene that never animated, which is
|
|
20
|
+
* exactly the failure a screenshot review cannot see.
|
|
21
|
+
* continuity a jump inside a shot, where the picture changes so much between two
|
|
22
|
+
* frames that the eye reads a cut rather than a move
|
|
23
|
+
* pacing a shot too short for anyone to read what it shows
|
|
24
|
+
* ending the clip lands mid-motion instead of on a held final state
|
|
25
|
+
*
|
|
26
|
+
* No model call. Everything here runs from pixels and the capture timeline, so it works
|
|
27
|
+
* in an environment with no egress and gives the same answer twice.
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
|
|
32
|
+
import { frameGrids, gridDelta, gridSpread } from './frame-grid.mjs';
|
|
33
|
+
|
|
34
|
+
/** Below this luminance spread a frame carries no legible content. */
|
|
35
|
+
const DEAD_STD_LUM = 3.0;
|
|
36
|
+
/** Mean absolute difference under this reads as an identical frame. */
|
|
37
|
+
const FROZEN_DELTA = 0.6;
|
|
38
|
+
/** Above this, consecutive frames *might* read as a cut rather than a move. */
|
|
39
|
+
const JUMP_DELTA = 34;
|
|
40
|
+
/** ...but only if the change also stands this many times clear of its neighbours. */
|
|
41
|
+
const JUMP_ISOLATION = 3.5;
|
|
42
|
+
/** Changes this close to either end of a shot belong to the cut, not to the shot. */
|
|
43
|
+
const BOUNDARY_SEC = 0.4;
|
|
44
|
+
/** A shot shorter than this cannot be read, whatever it contains. */
|
|
45
|
+
const MIN_SHOT_SEC = 1.2;
|
|
46
|
+
/** The tail that should be settled so a loop ends well. */
|
|
47
|
+
const ENDING_WINDOW_SEC = 0.6;
|
|
48
|
+
|
|
49
|
+
const clamp = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
|
|
50
|
+
const axis = (score, status, notes, evidence = {}) => ({
|
|
51
|
+
score: Math.round(clamp(score)),
|
|
52
|
+
status,
|
|
53
|
+
notes,
|
|
54
|
+
evidence,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
* @param {string[]} opts.frames analysis frames, in order
|
|
60
|
+
* @param {number} opts.fps rate the analysis frames were extracted at
|
|
61
|
+
* @param {{id:string,intent:string,startSec:number,endSec:number}[]} opts.timeline
|
|
62
|
+
* @param {number} opts.durationSec
|
|
63
|
+
*/
|
|
64
|
+
export function critique({ frames, fps, timeline = [], durationSec }) {
|
|
65
|
+
const axes = {};
|
|
66
|
+
const blockers = [];
|
|
67
|
+
const findings = [];
|
|
68
|
+
|
|
69
|
+
if (!frames || frames.length < 2) {
|
|
70
|
+
return {
|
|
71
|
+
axes: { deadFrames: axis(0, 'fail', 'No frames to judge') },
|
|
72
|
+
blockers: ['NO_FRAMES'],
|
|
73
|
+
findings: [],
|
|
74
|
+
score: 0,
|
|
75
|
+
pass: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { ok, grids, error } = frameGrids(frames);
|
|
80
|
+
|
|
81
|
+
// Without a pixel backend every measure below is null and the axes would fall back to
|
|
82
|
+
// constants — a score that measured nothing. Refuse rather than reassure.
|
|
83
|
+
if (!ok || grids.some((g) => !g)) {
|
|
84
|
+
return {
|
|
85
|
+
axes: {},
|
|
86
|
+
blockers: ['MEASUREMENT_UNAVAILABLE'],
|
|
87
|
+
findings: [{
|
|
88
|
+
kind: 'measurement',
|
|
89
|
+
detail: error || 'One or more frames could not be read',
|
|
90
|
+
}],
|
|
91
|
+
score: null,
|
|
92
|
+
pass: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stats = frames.map((file, i) => ({
|
|
97
|
+
file, i, t: i / fps, grid: grids[i], spread: gridSpread(grids[i]),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const shotAt = (t) => timeline.find((s) => t >= s.startSec && t < s.endSec) || null;
|
|
101
|
+
|
|
102
|
+
// ── dead frames ────────────────────────────────────────────────────────────
|
|
103
|
+
const dead = stats.filter((f) => (f.spread ?? 0) < DEAD_STD_LUM);
|
|
104
|
+
for (const f of dead) {
|
|
105
|
+
findings.push({
|
|
106
|
+
kind: 'dead-frame',
|
|
107
|
+
atSec: Number(f.t.toFixed(2)),
|
|
108
|
+
shot: shotAt(f.t)?.id ?? null,
|
|
109
|
+
detail: `frame carries almost no contrast (spread ${(f.spread ?? 0).toFixed(1)}) — nothing is legible here`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
axes.deadFrames = dead.length
|
|
113
|
+
? axis(100 - (dead.length / stats.length) * 300, 'fail', `${dead.length} blank or near-blank frame(s)`, { count: dead.length })
|
|
114
|
+
: axis(100, 'pass', 'every frame carries content');
|
|
115
|
+
if (dead.length / stats.length > 0.1) blockers.push('DEAD_FRAMES');
|
|
116
|
+
|
|
117
|
+
// ── frozen motion ──────────────────────────────────────────────────────────
|
|
118
|
+
// Judged per shot, and only against shots that claim movement. A `hold` is supposed to
|
|
119
|
+
// be still, so flagging stillness everywhere would bury the one case that matters.
|
|
120
|
+
const deltas = [];
|
|
121
|
+
for (let i = 1; i < stats.length; i++) {
|
|
122
|
+
deltas.push({ t: stats[i].t, d: gridDelta(stats[i - 1].grid, stats[i].grid), shot: shotAt(stats[i].t) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const movingShots = new Map();
|
|
126
|
+
for (const d of deltas) {
|
|
127
|
+
if (!d.shot) continue;
|
|
128
|
+
const entry = movingShots.get(d.shot.id) || { shot: d.shot, deltas: [] };
|
|
129
|
+
entry.deltas.push(d.d ?? 0);
|
|
130
|
+
movingShots.set(d.shot.id, entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let frozen = 0;
|
|
134
|
+
for (const { shot, deltas: ds } of movingShots.values()) {
|
|
135
|
+
const moved = ds.filter((d) => d > FROZEN_DELTA).length;
|
|
136
|
+
const share = ds.length ? moved / ds.length : 0;
|
|
137
|
+
// A shot whose frames never change at all, for more than a second, is not a beat —
|
|
138
|
+
// it is something that failed to render or failed to animate.
|
|
139
|
+
if (share < 0.05 && shot.endSec - shot.startSec > 1.5) {
|
|
140
|
+
frozen++;
|
|
141
|
+
findings.push({
|
|
142
|
+
kind: 'frozen-shot',
|
|
143
|
+
atSec: Number(shot.startSec.toFixed(2)),
|
|
144
|
+
shot: shot.id,
|
|
145
|
+
detail: `nothing moves for ${(shot.endSec - shot.startSec).toFixed(1)}s — intent was "${shot.intent}"`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
axes.frozenMotion = frozen
|
|
150
|
+
? axis(100 - frozen * 45, 'fail', `${frozen} shot(s) render a still picture`, { count: frozen })
|
|
151
|
+
: axis(100, 'pass', 'every shot shows movement');
|
|
152
|
+
if (frozen) blockers.push('FROZEN_SHOT');
|
|
153
|
+
|
|
154
|
+
// ── continuity ─────────────────────────────────────────────────────────────
|
|
155
|
+
// A jump at a shot boundary is a cut and is intended; a jump inside one is not.
|
|
156
|
+
//
|
|
157
|
+
// Absolute change is the wrong test on its own, and the first version of this got it
|
|
158
|
+
// wrong: scrolling the landing page across the boundary between its dark hero and the
|
|
159
|
+
// cream section below produces enormous frame-to-frame deltas, and every one was
|
|
160
|
+
// reported as a cut. That is a fast scroll doing exactly what it was asked to do.
|
|
161
|
+
//
|
|
162
|
+
// What distinguishes a discontinuity is that it is *isolated*: one large change sitting
|
|
163
|
+
// among small ones. Sustained motion makes its neighbours large too. So a delta is only
|
|
164
|
+
// a jump if it also stands well clear of the local median around it.
|
|
165
|
+
const localMedian = (i, radius = 3) => {
|
|
166
|
+
const near = [];
|
|
167
|
+
for (let j = Math.max(0, i - radius); j <= Math.min(deltas.length - 1, i + radius); j++) {
|
|
168
|
+
if (j !== i && deltas[j].d != null) near.push(deltas[j].d);
|
|
169
|
+
}
|
|
170
|
+
if (!near.length) return null;
|
|
171
|
+
near.sort((a, b) => a - b);
|
|
172
|
+
return near[Math.floor(near.length / 2)];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const jumps = deltas.filter((d, i) => {
|
|
176
|
+
if (d.d == null || d.d < JUMP_DELTA) return false;
|
|
177
|
+
if (!d.shot) return false;
|
|
178
|
+
// Both ends of a shot are legitimate cuts. Guarding only the start still reported the
|
|
179
|
+
// navigation into the next shot, because the analysis strip is coarser than the
|
|
180
|
+
// boundary and one delta straddles it — the change belongs to the goto, not to the
|
|
181
|
+
// shot it happens to be filed under.
|
|
182
|
+
if (d.t - d.shot.startSec <= BOUNDARY_SEC) return false;
|
|
183
|
+
if (d.shot.endSec - d.t <= BOUNDARY_SEC) return false;
|
|
184
|
+
const med = localMedian(i);
|
|
185
|
+
// No neighbours to compare against — do not guess.
|
|
186
|
+
if (med == null) return false;
|
|
187
|
+
return d.d > Math.max(JUMP_DELTA, med * JUMP_ISOLATION);
|
|
188
|
+
});
|
|
189
|
+
for (const j of jumps) {
|
|
190
|
+
findings.push({
|
|
191
|
+
kind: 'jump-cut',
|
|
192
|
+
atSec: Number(j.t.toFixed(2)),
|
|
193
|
+
shot: j.shot.id,
|
|
194
|
+
detail: `picture changes abruptly mid-shot (delta ${j.d.toFixed(0)}) — reads as a cut, not a move`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
axes.continuity = jumps.length
|
|
198
|
+
? axis(100 - jumps.length * 12, 'warn', `${jumps.length} abrupt change(s) inside a shot`, { count: jumps.length })
|
|
199
|
+
: axis(100, 'pass', 'motion is continuous within every shot');
|
|
200
|
+
|
|
201
|
+
// ── pacing ─────────────────────────────────────────────────────────────────
|
|
202
|
+
const rushed = timeline.filter((s) => s.endSec - s.startSec < MIN_SHOT_SEC);
|
|
203
|
+
for (const s of rushed) {
|
|
204
|
+
findings.push({
|
|
205
|
+
kind: 'rushed-shot',
|
|
206
|
+
atSec: Number(s.startSec.toFixed(2)),
|
|
207
|
+
shot: s.id,
|
|
208
|
+
detail: `${(s.endSec - s.startSec).toFixed(2)}s is too short to read — under the ${MIN_SHOT_SEC}s floor`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
axes.pacing = timeline.length
|
|
212
|
+
? (rushed.length
|
|
213
|
+
? axis(100 - (rushed.length / timeline.length) * 100, 'warn', `${rushed.length} shot(s) too short to read`, { count: rushed.length })
|
|
214
|
+
: axis(100, 'pass', 'every shot holds long enough to read'))
|
|
215
|
+
: axis(50, 'skip', 'no timeline supplied');
|
|
216
|
+
|
|
217
|
+
// ── ending ─────────────────────────────────────────────────────────────────
|
|
218
|
+
// The craft rule in the recorder skill: land on a strong final state so a loop ends
|
|
219
|
+
// well. Measured as stillness across the tail.
|
|
220
|
+
const tail = deltas.filter((d) => d.t > (durationSec ?? stats[stats.length - 1].t) - ENDING_WINDOW_SEC);
|
|
221
|
+
const tailMoving = tail.filter((d) => (d.d ?? 0) > JUMP_DELTA / 3).length;
|
|
222
|
+
if (tail.length && tailMoving / tail.length > 0.5) {
|
|
223
|
+
findings.push({
|
|
224
|
+
kind: 'unsettled-ending',
|
|
225
|
+
atSec: Number((durationSec ?? 0).toFixed(2)),
|
|
226
|
+
shot: timeline[timeline.length - 1]?.id ?? null,
|
|
227
|
+
detail: 'the clip ends mid-motion — a loop will cut on a moving frame',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
axes.ending = tail.length
|
|
231
|
+
? (tailMoving / tail.length > 0.5
|
|
232
|
+
? axis(45, 'warn', 'ends mid-motion rather than on a held state')
|
|
233
|
+
: axis(100, 'pass', 'settles before the end'))
|
|
234
|
+
: axis(50, 'skip', 'clip too short to judge its ending');
|
|
235
|
+
|
|
236
|
+
const score = weighted(axes);
|
|
237
|
+
return {
|
|
238
|
+
axes,
|
|
239
|
+
blockers,
|
|
240
|
+
findings: findings.sort((a, b) => a.atSec - b.atSec),
|
|
241
|
+
score,
|
|
242
|
+
pass: blockers.length === 0 && score != null && score >= 70,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const CRITIC_WEIGHTS = {
|
|
247
|
+
deadFrames: 0.3,
|
|
248
|
+
frozenMotion: 0.3,
|
|
249
|
+
continuity: 0.15,
|
|
250
|
+
pacing: 0.15,
|
|
251
|
+
ending: 0.1,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Skipped axes renormalise rather than scoring a default — the design-eval convention. */
|
|
255
|
+
function weighted(axes) {
|
|
256
|
+
let sum = 0;
|
|
257
|
+
let wsum = 0;
|
|
258
|
+
for (const [k, w] of Object.entries(CRITIC_WEIGHTS)) {
|
|
259
|
+
if (axes[k] && axes[k].status !== 'skip') {
|
|
260
|
+
sum += axes[k].score * w;
|
|
261
|
+
wsum += w;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return wsum ? Math.round(sum / wsum) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function writeCritique(dir, result, meta = {}) {
|
|
268
|
+
const file = path.join(dir, 'critique.json');
|
|
269
|
+
fs.writeFileSync(file, JSON.stringify({ ...meta, ...result }, null, 2));
|
|
270
|
+
return file;
|
|
271
|
+
}
|
package/src/cursor.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic cursor overlay.
|
|
3
|
+
*
|
|
4
|
+
* Playwright's mouse is real as far as the page is concerned, but the OS pointer is not
|
|
5
|
+
* part of what the compositor paints — so a screencast of a click shows the effect and
|
|
6
|
+
* never the cause. For a SaaS-style walkthrough that is fatal: the viewer sees fields
|
|
7
|
+
* filling themselves. This draws a pointer that the recording can see, plus a ripple on
|
|
8
|
+
* click so the moment of interaction reads at speed.
|
|
9
|
+
*
|
|
10
|
+
* Installed with `addInitScript`, so it survives every navigation in a multi-page story
|
|
11
|
+
* without the script needing to re-inject it per page.
|
|
12
|
+
*
|
|
13
|
+
* The overlay is `pointer-events:none` and sits above everything, so it cannot alter what
|
|
14
|
+
* it is documenting. It is also marked `data-recording-chrome`, which the frame critic
|
|
15
|
+
* uses to tell studio UI apart from recorder furniture.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const CURSOR_INIT = `(() => {
|
|
19
|
+
if (window.__rec) return;
|
|
20
|
+
const SIZE = 22;
|
|
21
|
+
let el = null, ripple = null;
|
|
22
|
+
|
|
23
|
+
function ensure() {
|
|
24
|
+
if (el && document.body.contains(el)) return;
|
|
25
|
+
el = document.createElement('div');
|
|
26
|
+
el.setAttribute('data-recording-chrome', 'cursor');
|
|
27
|
+
el.style.cssText = [
|
|
28
|
+
'position:fixed','left:0','top:0','width:' + SIZE + 'px','height:' + SIZE + 'px',
|
|
29
|
+
'pointer-events:none','z-index:2147483647','will-change:transform',
|
|
30
|
+
'transform:translate3d(-100px,-100px,0)','opacity:0','transition:opacity .18s ease',
|
|
31
|
+
].join(';');
|
|
32
|
+
el.innerHTML =
|
|
33
|
+
'<svg width="' + SIZE + '" height="' + SIZE + '" viewBox="0 0 22 22" fill="none">' +
|
|
34
|
+
'<path d="M4 2.5 L4 17 L8 13.4 L10.6 19 L13.4 17.7 L10.8 12.3 L16 12 Z" ' +
|
|
35
|
+
'fill="#ffffff" stroke="rgba(0,0,0,.55)" stroke-width="1.1" stroke-linejoin="round"/></svg>';
|
|
36
|
+
|
|
37
|
+
ripple = document.createElement('div');
|
|
38
|
+
ripple.setAttribute('data-recording-chrome', 'ripple');
|
|
39
|
+
ripple.style.cssText = [
|
|
40
|
+
'position:fixed','left:0','top:0','width:34px','height:34px','margin:-17px 0 0 -17px',
|
|
41
|
+
'border-radius:50%','pointer-events:none','z-index:2147483646','opacity:0',
|
|
42
|
+
'border:2px solid rgba(255,255,255,.85)','will-change:transform,opacity',
|
|
43
|
+
].join(';');
|
|
44
|
+
|
|
45
|
+
document.body.appendChild(el);
|
|
46
|
+
document.body.appendChild(ripple);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
window.__rec = {
|
|
50
|
+
/** Place the pointer. Called once per animation step, so no CSS transition on transform. */
|
|
51
|
+
cursor(x, y) {
|
|
52
|
+
ensure();
|
|
53
|
+
el.style.opacity = '1';
|
|
54
|
+
el.style.transform = 'translate3d(' + x + 'px,' + y + 'px,0)';
|
|
55
|
+
window.__rec._x = x; window.__rec._y = y;
|
|
56
|
+
},
|
|
57
|
+
hide() { if (el) el.style.opacity = '0'; },
|
|
58
|
+
/** A one-shot ring at the current point. Deliberately short — it must not trail. */
|
|
59
|
+
ripple(x, y) {
|
|
60
|
+
ensure();
|
|
61
|
+
const px = x == null ? window.__rec._x : x;
|
|
62
|
+
const py = y == null ? window.__rec._y : y;
|
|
63
|
+
ripple.style.transition = 'none';
|
|
64
|
+
ripple.style.transform = 'translate3d(' + px + 'px,' + py + 'px,0) scale(.35)';
|
|
65
|
+
ripple.style.opacity = '.9';
|
|
66
|
+
requestAnimationFrame(() => {
|
|
67
|
+
ripple.style.transition = 'transform .42s cubic-bezier(.2,.7,.3,1), opacity .42s ease';
|
|
68
|
+
ripple.style.transform = 'translate3d(' + px + 'px,' + py + 'px,0) scale(1.25)';
|
|
69
|
+
ripple.style.opacity = '0';
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
_x: 0, _y: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// The drawn cursor follows the real pointer by listening, rather than being positioned
|
|
76
|
+
// by the driver on every step. Each driver-side call is an IPC round trip, and doing
|
|
77
|
+
// one per animation frame was costing more time than the frame itself — it held capture
|
|
78
|
+
// to about 20fps during motion. Listening keeps the two in sync by construction.
|
|
79
|
+
addEventListener('pointermove', (e) => {
|
|
80
|
+
if (e.isTrusted === false && !e.__rec) return;
|
|
81
|
+
window.__rec.cursor(e.clientX, e.clientY);
|
|
82
|
+
}, { capture: true, passive: true });
|
|
83
|
+
|
|
84
|
+
/** Eased scroll driven entirely in-page, so the driver waits instead of stepping. */
|
|
85
|
+
window.__rec.scrollTo = (target, ms) => new Promise((resolve) => {
|
|
86
|
+
const from = window.scrollY;
|
|
87
|
+
const delta = target - from;
|
|
88
|
+
if (!ms || Math.abs(delta) < 1) { window.scrollTo(0, target); return resolve(); }
|
|
89
|
+
const t0 = performance.now();
|
|
90
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
91
|
+
(function step(now) {
|
|
92
|
+
const t = Math.min(1, (now - t0) / ms);
|
|
93
|
+
window.scrollTo(0, from + delta * ease(t));
|
|
94
|
+
if (t < 1) requestAnimationFrame(step);
|
|
95
|
+
else resolve();
|
|
96
|
+
})(t0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (document.body) ensure();
|
|
100
|
+
else document.addEventListener('DOMContentLoaded', ensure, { once: true });
|
|
101
|
+
})();`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hides native carets and scrollbars, which read as artefacts in a finished clip, and
|
|
105
|
+
* neutralises smooth-scroll so the recorder owns scroll timing rather than the page.
|
|
106
|
+
*/
|
|
107
|
+
export const POLISH_INIT = `(() => {
|
|
108
|
+
const style = document.createElement('style');
|
|
109
|
+
style.setAttribute('data-recording-chrome', 'polish');
|
|
110
|
+
style.textContent = [
|
|
111
|
+
'html{scroll-behavior:auto !important}',
|
|
112
|
+
'::-webkit-scrollbar{width:0 !important;height:0 !important}',
|
|
113
|
+
'html{scrollbar-width:none !important}',
|
|
114
|
+
].join('');
|
|
115
|
+
const add = () => document.documentElement.appendChild(style);
|
|
116
|
+
if (document.documentElement) add();
|
|
117
|
+
else document.addEventListener('DOMContentLoaded', add, { once: true });
|
|
118
|
+
})();`;
|
package/src/edit-cli.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Edit CLI — cut a short piece from a recorded master.
|
|
4
|
+
*
|
|
5
|
+
* npm run record:edit -- recordings/studio-tour/social.edit.json
|
|
6
|
+
* npm run record:edit -- <edit.json> --check # resolve and report, encode nothing
|
|
7
|
+
*
|
|
8
|
+
* The master is the source of truth. A social cut is a reading of it, not a second
|
|
9
|
+
* recording, so the two cannot drift apart.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { chromium } from 'playwright-core';
|
|
14
|
+
|
|
15
|
+
import { launch } from './runner.mjs';
|
|
16
|
+
import { validateEdit, resolve, budgetReport, loadCapture, ASPECTS } from './edit.mjs';
|
|
17
|
+
import { renderCaptions } from './caption.mjs';
|
|
18
|
+
import { renderMotionSequence } from './motion.mjs';
|
|
19
|
+
import { cutEdit, probeDuration } from './assemble.mjs';
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const has = (name) => argv.includes(`--${name}`);
|
|
23
|
+
const editPath = argv.find((a) => !a.startsWith('--') && a.endsWith('.json'));
|
|
24
|
+
|
|
25
|
+
if (!editPath || !fs.existsSync(editPath)) {
|
|
26
|
+
console.error('usage: npm run record:edit -- <path/to/edit.json> [--check]');
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const root = process.cwd();
|
|
31
|
+
const edit = JSON.parse(fs.readFileSync(editPath, 'utf8'));
|
|
32
|
+
|
|
33
|
+
console.log(`\n EDIT ${edit.name}\n ────────────────────────────────────`);
|
|
34
|
+
|
|
35
|
+
const errors = validateEdit(edit);
|
|
36
|
+
if (errors.length) {
|
|
37
|
+
console.error(`\n ✗ ${errors.length} problem(s) in ${editPath}\n`);
|
|
38
|
+
for (const e of errors) console.error(` ${e}`);
|
|
39
|
+
console.error('');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let capture;
|
|
44
|
+
try {
|
|
45
|
+
capture = loadCapture(root, edit.source);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error(`\n ✗ ${e.message}\n`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { segments, problems, totalSec, overlapSec, sourceDurationSec } = resolve(edit, capture);
|
|
52
|
+
if (problems.length) {
|
|
53
|
+
console.error(`\n ✗ ${problems.length} segment(s) do not resolve against ${edit.source}\n`);
|
|
54
|
+
for (const p of problems) console.error(` ${p}`);
|
|
55
|
+
console.error('');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(` ✓ ${segments.length} segment(s) resolve against ${edit.source} (${sourceDurationSec.toFixed(1)}s master)`);
|
|
60
|
+
for (const s of segments) {
|
|
61
|
+
const speed = s.speed !== 1 ? ` @${s.speed}x` : '';
|
|
62
|
+
const cap = s.caption ? ` "${s.caption.slice(0, 42)}${s.caption.length > 42 ? '…' : ''}"` : '';
|
|
63
|
+
// The transition sits on its own line between the two shots it joins, which is where it
|
|
64
|
+
// happens. Inline, it read as a property of the segment and pushed the columns out of
|
|
65
|
+
// alignment for every other row.
|
|
66
|
+
if (s.transition && s.transition.type !== 'cut') {
|
|
67
|
+
console.log(` ⌄ ${s.transition.type} ${s.transition.durationSec}s`);
|
|
68
|
+
}
|
|
69
|
+
console.log(` ${(s.shot || 'abs').padEnd(9)} ${s.start.toFixed(2)}-${s.end.toFixed(2)}s → ${s.outSec.toFixed(2)}s${speed}${cap}`);
|
|
70
|
+
}
|
|
71
|
+
console.log(
|
|
72
|
+
` ✓ ${totalSec.toFixed(1)}s total`
|
|
73
|
+
+ (overlapSec > 0 ? ` (${overlapSec.toFixed(1)}s absorbed by transitions)` : '')
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Narrowing a landscape master to a vertical frame is lossy in a way that no focusX can
|
|
77
|
+
// rescue, and the loss is invisible until the render is watched. Report it in the units
|
|
78
|
+
// that matter — how much of the frame survives — and name the alternative, which is to
|
|
79
|
+
// record the story at that viewport instead. These pages are responsive; a vertical take
|
|
80
|
+
// reflows and keeps the whole design in frame.
|
|
81
|
+
const shot = ASPECTS[edit.aspect || '16:9'];
|
|
82
|
+
const src = capture.viewport;
|
|
83
|
+
if (src?.width && shot) {
|
|
84
|
+
const srcRatio = src.width / src.height;
|
|
85
|
+
const outRatio = shot.w / shot.h;
|
|
86
|
+
if (outRatio < srcRatio - 0.02 && segments.some((s) => s.fit !== 'contain')) {
|
|
87
|
+
const kept = ((src.height * outRatio) / src.width) * 100;
|
|
88
|
+
const level = kept < 60 ? '!' : '·';
|
|
89
|
+
console.log(
|
|
90
|
+
` ${level} cropping ${src.width}x${src.height} to ${edit.aspect} keeps ${kept.toFixed(0)}% of the width`
|
|
91
|
+
);
|
|
92
|
+
if (kept < 60) {
|
|
93
|
+
console.log(` a native take keeps all of it: npm run record -- recordings/${edit.source}/script.json --viewport ${shot.w}x${shot.h}`);
|
|
94
|
+
console.log(` then point this edit at "source": "${edit.source}-vertical"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const budget = budgetReport(totalSec, edit.budgetSec);
|
|
100
|
+
if (budget && !budget.ok) {
|
|
101
|
+
// Reported, not enforced. Silently retiming to hit a number changes the pacing the
|
|
102
|
+
// director chose, and pacing is the whole point of a short format.
|
|
103
|
+
console.warn(` ! ${budget.totalSec.toFixed(1)}s is over the ${budget.budgetSec}s budget — ${budget.hint}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An audio bed is named in the edit but lives on disk, so it is checked here rather than
|
|
107
|
+
// in validation. Failing at the graph stage would mean discovering it after the captions
|
|
108
|
+
// have been rendered in a browser.
|
|
109
|
+
let audio = null;
|
|
110
|
+
if (edit.audio?.file) {
|
|
111
|
+
const file = path.isAbsolute(edit.audio.file) ? edit.audio.file : path.join(root, edit.audio.file);
|
|
112
|
+
if (!fs.existsSync(file)) {
|
|
113
|
+
console.error(`\n ✗ audio.file not found: ${edit.audio.file}\n`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
audio = { ...edit.audio, file };
|
|
117
|
+
const bed = path.basename(file);
|
|
118
|
+
console.log(` ✓ audio bed ${bed} at ${audio.gainDb ?? -18}dB${audio.loop ? ', looped' : ''}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (has('check')) {
|
|
122
|
+
console.log('\n OK check — nothing was encoded\n');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const aspect = edit.aspect || '16:9';
|
|
127
|
+
const size = ASPECTS[aspect];
|
|
128
|
+
const fps = edit.fps || capture.fps || 30;
|
|
129
|
+
const outDir = path.join(root, 'recordings', edit.source, 'out');
|
|
130
|
+
const master = path.join(root, capture.outputs?.master || path.join('recordings', edit.source, 'out', `${edit.source}.mp4`));
|
|
131
|
+
|
|
132
|
+
if (!fs.existsSync(master)) {
|
|
133
|
+
console.error(`\n ✗ master not found at ${path.relative(root, master)} — re-run the recording\n`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── captions ──────────────────────────────────────────────────────────────────
|
|
138
|
+
const capDir = path.join(root, 'recordings', edit.source, '.captions');
|
|
139
|
+
const captions = segments
|
|
140
|
+
.filter((s) => s.caption)
|
|
141
|
+
.map((s) => ({ key: `cap-${s.index}`, text: s.caption, kicker: s.kicker }));
|
|
142
|
+
|
|
143
|
+
// ── motion overlays ───────────────────────────────────────────────────────────
|
|
144
|
+
// Rendered before the stills, because a segment that animates does not want one. Each is
|
|
145
|
+
// a PNG sequence at the edit's frame rate, produced by stepping the animation in Node and
|
|
146
|
+
// screenshotting Chromium once per frame — the same determinism the capture mode relies on.
|
|
147
|
+
const motionDir = path.join(root, 'recordings', edit.source, '.motion');
|
|
148
|
+
fs.rmSync(motionDir, { recursive: true, force: true });
|
|
149
|
+
let motionFrames = 0;
|
|
150
|
+
for (const s of segments) {
|
|
151
|
+
if (!s.motion) continue;
|
|
152
|
+
const spec = {
|
|
153
|
+
text: s.caption,
|
|
154
|
+
kicker: s.kicker,
|
|
155
|
+
durationSec: s.captionForSec != null ? Math.min(s.captionForSec, s.outSec) : s.outSec,
|
|
156
|
+
...s.motion,
|
|
157
|
+
};
|
|
158
|
+
const seq = await renderMotionSequence(spec, {
|
|
159
|
+
chromium, launch, dir: path.join(motionDir, `seg-${s.index}`),
|
|
160
|
+
width: size.w, height: size.h, aspect, fps, root,
|
|
161
|
+
});
|
|
162
|
+
s.motionSeq = seq;
|
|
163
|
+
motionFrames += seq.count;
|
|
164
|
+
}
|
|
165
|
+
if (motionFrames) {
|
|
166
|
+
console.log(` ✓ ${motionFrames} animated overlay frame(s) across ${segments.filter((s) => s.motion).length} segment(s)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stillCaptions = captions.filter((c) => !segments.find((s) => `cap-${s.index}` === c.key)?.motion);
|
|
170
|
+
|
|
171
|
+
let rendered = new Map();
|
|
172
|
+
if (stillCaptions.length) {
|
|
173
|
+
rendered = await renderCaptions(stillCaptions, {
|
|
174
|
+
chromium, launch, dir: capDir, width: size.w, height: size.h, aspect, root,
|
|
175
|
+
});
|
|
176
|
+
console.log(` ✓ ${rendered.size} still caption(s) rendered in the studio typeface`);
|
|
177
|
+
}
|
|
178
|
+
for (const s of segments) {
|
|
179
|
+
if (s.caption && !s.motion) s.captionPng = rendered.get(`cap-${s.index}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── cut ───────────────────────────────────────────────────────────────────────
|
|
183
|
+
const out = path.join(outDir, `${edit.name}.mp4`);
|
|
184
|
+
try {
|
|
185
|
+
cutEdit({
|
|
186
|
+
input: master,
|
|
187
|
+
out,
|
|
188
|
+
segments,
|
|
189
|
+
size,
|
|
190
|
+
focusX: edit.focusX ?? 0.5,
|
|
191
|
+
fps,
|
|
192
|
+
crf: edit.crf ?? 19,
|
|
193
|
+
audio,
|
|
194
|
+
});
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(capDir, { recursive: true, force: true });
|
|
197
|
+
fs.rmSync(motionDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const actual = probeDuration(out);
|
|
201
|
+
console.log(` ✓ ${path.relative(root, out)} ${size.w}x${size.h} ${actual ? actual.toFixed(1) + 's' : ''} (${(fs.statSync(out).size / 1e6).toFixed(1)} MB)`);
|
|
202
|
+
|
|
203
|
+
// The filtergraph is long and one wrong label silently drops a segment, so the result is
|
|
204
|
+
// checked against what the edit asked for rather than assumed.
|
|
205
|
+
if (actual && Math.abs(actual - totalSec) > 0.5) {
|
|
206
|
+
console.error(`\n ✗ output is ${actual.toFixed(2)}s but the edit asks for ${totalSec.toFixed(2)}s — a segment did not make it\n`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log('\n OK\n');
|
|
211
|
+
process.exit(0);
|