@mehshekari/gsap-motion 3.2.0 → 3.4.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/README.md +3 -1
- package/bin/gsap-motion.mjs +21 -0
- package/package.json +1 -1
- package/skill/SKILL.md +44 -37
- package/skill/reference/devices.md +110 -0
- package/skill/reference/motion-design.md +8 -5
- package/skill/reference/project-rules.md +16 -0
- package/skill/reference/refine.md +112 -0
- package/skill/reference/routing.md +29 -0
- package/skill/reference/timeline.md +40 -0
- package/skill/scripts/capture-motion.mjs +519 -0
- package/skill/scripts/explain-motion.mjs +575 -0
- package/skill/scripts/lib/rules.mjs +321 -10
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Watches a page animate, and reports what it saw.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is an observation, never a verdict. It captures frames at
|
|
7
|
+
* given times, reads the timeline GSAP is actually running, and counts the
|
|
8
|
+
* layouts, style recalculations and frames the browser did — and then says so,
|
|
9
|
+
* with numbers. Whether 14 layouts in a second is a problem depends on the
|
|
10
|
+
* animation, and that judgement belongs to whoever reads this.
|
|
11
|
+
*
|
|
12
|
+
* It drives Chrome over the DevTools Protocol through a pipe — `stdin` and
|
|
13
|
+
* `stdout` of two extra file descriptors — rather than a WebSocket, because
|
|
14
|
+
* Node 18 has no global WebSocket and this skill installs nothing. It uses
|
|
15
|
+
* whatever Chrome the machine already has.
|
|
16
|
+
*
|
|
17
|
+
* Usage, from the project root:
|
|
18
|
+
* node <skill-dir>/scripts/capture-motion.mjs <url> [options]
|
|
19
|
+
*
|
|
20
|
+
* --at 0,300,900 when to capture, in ms after the page settles
|
|
21
|
+
* --wait 400 settle time before the first capture (default 400)
|
|
22
|
+
* --out <dir> where frames go (default .gsap-motion/inspect)
|
|
23
|
+
* --scroll <px|sel> scroll before capturing: a number, or to a selector
|
|
24
|
+
* --hover <selector> move the pointer onto an element, at its live box
|
|
25
|
+
* --click <selector> click an element
|
|
26
|
+
* --reduced emulate prefers-reduced-motion: reduce
|
|
27
|
+
* --dark emulate prefers-color-scheme: dark
|
|
28
|
+
* --mobile 390x844 at dpr 3, with touch input
|
|
29
|
+
* --cpu 4 throttle the CPU by this factor
|
|
30
|
+
* --json machine-readable, for scripts
|
|
31
|
+
* --chrome <path> a browser to use, overriding the search
|
|
32
|
+
*
|
|
33
|
+
* `--scroll`, `--hover` and `--click` run in the order they are written, before
|
|
34
|
+
* the first capture. An animation that needs a pointer or a scroll position is
|
|
35
|
+
* invisible without them.
|
|
36
|
+
*
|
|
37
|
+
* `--mobile` makes input touch, so it cannot be combined with `--hover`: a
|
|
38
|
+
* device with a coarse pointer has no hover, which is the point.
|
|
39
|
+
*/
|
|
40
|
+
import { spawn } from "node:child_process";
|
|
41
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { homedir, tmpdir } from "node:os";
|
|
43
|
+
import { join, resolve } from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
// --- The browser -------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Where a Chrome usually is, per platform. `CHROME_PATH` wins over all of it. */
|
|
49
|
+
export function findChrome(override) {
|
|
50
|
+
const candidates = [
|
|
51
|
+
override,
|
|
52
|
+
process.env.GSAP_MOTION_CHROME,
|
|
53
|
+
process.env.CHROME_PATH,
|
|
54
|
+
...(process.platform === "win32"
|
|
55
|
+
? [
|
|
56
|
+
`${process.env.ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
57
|
+
`${process.env["ProgramFiles(x86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
58
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
59
|
+
`${process.env.ProgramFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
60
|
+
]
|
|
61
|
+
: process.platform === "darwin"
|
|
62
|
+
? [
|
|
63
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
64
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
65
|
+
`${homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
66
|
+
]
|
|
67
|
+
: [
|
|
68
|
+
"/usr/bin/google-chrome",
|
|
69
|
+
"/usr/bin/google-chrome-stable",
|
|
70
|
+
"/usr/bin/chromium",
|
|
71
|
+
"/usr/bin/chromium-browser",
|
|
72
|
+
"/snap/bin/chromium",
|
|
73
|
+
]),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
return candidates.find((path) => path && existsSync(path)) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A CDP connection over Chrome's pipe transport: commands into file descriptor
|
|
81
|
+
* 3, replies and events out of 4, each message a JSON object followed by a NUL.
|
|
82
|
+
*/
|
|
83
|
+
export function connect(executable, { headless = true } = {}) {
|
|
84
|
+
const profile = join(tmpdir(), `gsap-motion-inspect-${process.pid}`);
|
|
85
|
+
const chrome = spawn(
|
|
86
|
+
executable,
|
|
87
|
+
[
|
|
88
|
+
...(headless ? ["--headless=new"] : []),
|
|
89
|
+
"--remote-debugging-pipe",
|
|
90
|
+
"--no-first-run",
|
|
91
|
+
"--no-default-browser-check",
|
|
92
|
+
"--disable-gpu",
|
|
93
|
+
"--disable-extensions",
|
|
94
|
+
`--user-data-dir=${profile}`,
|
|
95
|
+
"about:blank",
|
|
96
|
+
],
|
|
97
|
+
{ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"] },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const [, , stderr, toBrowser, fromBrowser] = chrome.stdio;
|
|
101
|
+
const stderrLines = [];
|
|
102
|
+
stderr.on("data", (chunk) => stderrLines.push(chunk.toString()));
|
|
103
|
+
|
|
104
|
+
const waiting = new Map();
|
|
105
|
+
let buffer = "";
|
|
106
|
+
|
|
107
|
+
fromBrowser.on("data", (chunk) => {
|
|
108
|
+
buffer += chunk.toString("utf8");
|
|
109
|
+
let end = buffer.indexOf("\0");
|
|
110
|
+
while (end !== -1) {
|
|
111
|
+
let message;
|
|
112
|
+
try {
|
|
113
|
+
message = JSON.parse(buffer.slice(0, end));
|
|
114
|
+
} catch {
|
|
115
|
+
message = null;
|
|
116
|
+
}
|
|
117
|
+
buffer = buffer.slice(end + 1);
|
|
118
|
+
const pending = message?.id && waiting.get(message.id);
|
|
119
|
+
if (pending) {
|
|
120
|
+
waiting.delete(message.id);
|
|
121
|
+
if (message.error) pending.reject(new Error(message.error.message ?? "CDP error"));
|
|
122
|
+
else pending.resolve(message.result);
|
|
123
|
+
}
|
|
124
|
+
end = buffer.indexOf("\0");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let nextId = 0;
|
|
129
|
+
const send = (method, params = {}, sessionId) =>
|
|
130
|
+
new Promise((accept, reject) => {
|
|
131
|
+
const id = (nextId += 1);
|
|
132
|
+
waiting.set(id, { resolve: accept, reject });
|
|
133
|
+
toBrowser.write(
|
|
134
|
+
`${JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })}\0`,
|
|
135
|
+
);
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
if (!waiting.has(id)) return;
|
|
138
|
+
waiting.delete(id);
|
|
139
|
+
reject(new Error(`${method} did not answer within 20s`));
|
|
140
|
+
}, 20000);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const close = async () => {
|
|
144
|
+
try {
|
|
145
|
+
await send("Browser.close");
|
|
146
|
+
} catch {
|
|
147
|
+
chrome.kill();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { send, close, stderrLines };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Options -----------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** A number of milliseconds, or null when the text is not one. */
|
|
157
|
+
const asNumber = (text) => {
|
|
158
|
+
const value = Number(text);
|
|
159
|
+
return Number.isFinite(value) ? value : null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reads the command line. Exported because the parsing is worth testing without
|
|
164
|
+
* a browser, and because a wrong `--at` should fail before Chrome starts.
|
|
165
|
+
*/
|
|
166
|
+
export function parseOptions(argv) {
|
|
167
|
+
const options = {
|
|
168
|
+
url: null,
|
|
169
|
+
at: [0, 300, 900],
|
|
170
|
+
wait: 400,
|
|
171
|
+
out: join(".gsap-motion", "inspect"),
|
|
172
|
+
steps: [],
|
|
173
|
+
reduced: false,
|
|
174
|
+
dark: false,
|
|
175
|
+
mobile: false,
|
|
176
|
+
cpu: 1,
|
|
177
|
+
json: false,
|
|
178
|
+
chrome: null,
|
|
179
|
+
errors: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
183
|
+
const argument = argv[index];
|
|
184
|
+
const value = () => argv[(index += 1)];
|
|
185
|
+
|
|
186
|
+
switch (argument) {
|
|
187
|
+
case "--at": {
|
|
188
|
+
const times = String(value() ?? "")
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((part) => asNumber(part.trim()));
|
|
191
|
+
if (times.length === 0 || times.some((time) => time === null || time < 0)) {
|
|
192
|
+
options.errors.push("--at takes milliseconds, such as --at 0,300,900");
|
|
193
|
+
} else {
|
|
194
|
+
options.at = times.sort((a, b) => a - b);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "--wait": {
|
|
199
|
+
const ms = asNumber(value());
|
|
200
|
+
if (ms === null || ms < 0) options.errors.push("--wait takes milliseconds");
|
|
201
|
+
else options.wait = ms;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "--out":
|
|
205
|
+
options.out = value() ?? options.out;
|
|
206
|
+
break;
|
|
207
|
+
case "--scroll":
|
|
208
|
+
options.steps.push({ type: "scroll", value: value() ?? "" });
|
|
209
|
+
break;
|
|
210
|
+
case "--hover":
|
|
211
|
+
options.steps.push({ type: "hover", value: value() ?? "" });
|
|
212
|
+
break;
|
|
213
|
+
case "--click":
|
|
214
|
+
options.steps.push({ type: "click", value: value() ?? "" });
|
|
215
|
+
break;
|
|
216
|
+
case "--reduced":
|
|
217
|
+
options.reduced = true;
|
|
218
|
+
break;
|
|
219
|
+
case "--dark":
|
|
220
|
+
options.dark = true;
|
|
221
|
+
break;
|
|
222
|
+
case "--mobile":
|
|
223
|
+
options.mobile = true;
|
|
224
|
+
break;
|
|
225
|
+
case "--cpu": {
|
|
226
|
+
const rate = asNumber(value());
|
|
227
|
+
if (rate === null || rate < 1) options.errors.push("--cpu takes a factor of 1 or more");
|
|
228
|
+
else options.cpu = rate;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "--json":
|
|
232
|
+
options.json = true;
|
|
233
|
+
break;
|
|
234
|
+
case "--chrome":
|
|
235
|
+
options.chrome = value() ?? null;
|
|
236
|
+
break;
|
|
237
|
+
default:
|
|
238
|
+
if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
|
|
239
|
+
else if (options.url === null) options.url = argument;
|
|
240
|
+
else options.errors.push(`Unexpected argument: ${argument}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (options.url === null) options.errors.push("No URL. Pass the page to watch.");
|
|
245
|
+
|
|
246
|
+
/** A coarse pointer has no hover; emulating both would measure something no device is. */
|
|
247
|
+
if (options.mobile && options.steps.some((step) => step.type === "hover")) {
|
|
248
|
+
options.errors.push(
|
|
249
|
+
"--mobile and --hover cannot be combined: mobile emulation makes input touch, and a touch device has no hover.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return options;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- What runs in the page ---------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The timeline GSAP is running, read from the page itself rather than guessed
|
|
260
|
+
* from the source. Without GSAP on the page this returns null, which is a
|
|
261
|
+
* finding of its own: the animation never started.
|
|
262
|
+
*/
|
|
263
|
+
const READ_TIMELINE = `(() => {
|
|
264
|
+
if (typeof gsap === "undefined") return null;
|
|
265
|
+
const children = gsap.globalTimeline.getChildren(true, true, true);
|
|
266
|
+
return {
|
|
267
|
+
time: gsap.globalTimeline.time(),
|
|
268
|
+
children: children.map((child) => ({
|
|
269
|
+
targets: (child.targets?.() ?? []).map((target) =>
|
|
270
|
+
target?.id ? "#" + target.id :
|
|
271
|
+
target?.tagName ? target.tagName.toLowerCase() :
|
|
272
|
+
String(target)
|
|
273
|
+
),
|
|
274
|
+
duration: Number(child.duration?.().toFixed?.(3) ?? child.duration?.() ?? 0),
|
|
275
|
+
repeat: child.repeat?.() ?? 0,
|
|
276
|
+
yoyo: child.yoyo?.() ?? false,
|
|
277
|
+
paused: child.paused?.() ?? false,
|
|
278
|
+
ease: typeof child.vars?.ease === "string" ? child.vars.ease : child.vars?.ease ? "(function)" : null,
|
|
279
|
+
props: Object.keys(child.vars ?? {}).filter(
|
|
280
|
+
(key) => !["ease", "duration", "repeat", "yoyo", "scrollTrigger", "onComplete", "onUpdate", "delay", "stagger"].includes(key),
|
|
281
|
+
),
|
|
282
|
+
})),
|
|
283
|
+
};
|
|
284
|
+
})()`;
|
|
285
|
+
|
|
286
|
+
/** The live box of a selector, so a pointer lands on an element that is moving. */
|
|
287
|
+
const boxOf = (selector) => `(() => {
|
|
288
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
289
|
+
if (!element) return null;
|
|
290
|
+
const box = element.getBoundingClientRect();
|
|
291
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2, width: box.width, height: box.height };
|
|
292
|
+
})()`;
|
|
293
|
+
|
|
294
|
+
// --- The run -----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
297
|
+
|
|
298
|
+
async function capture(options) {
|
|
299
|
+
const executable = findChrome(options.chrome);
|
|
300
|
+
if (!executable) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const browser = connect(executable);
|
|
307
|
+
const notes = [];
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
|
|
311
|
+
const { sessionId } = await browser.send("Target.attachToTarget", {
|
|
312
|
+
targetId,
|
|
313
|
+
flatten: true,
|
|
314
|
+
});
|
|
315
|
+
const call = (method, params) => browser.send(method, params, sessionId);
|
|
316
|
+
const evaluate = async (expression) => {
|
|
317
|
+
const { result } = await call("Runtime.evaluate", {
|
|
318
|
+
expression,
|
|
319
|
+
returnByValue: true,
|
|
320
|
+
awaitPromise: true,
|
|
321
|
+
});
|
|
322
|
+
return result.value;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await call("Page.enable");
|
|
326
|
+
await call("Runtime.enable");
|
|
327
|
+
await call("Performance.enable");
|
|
328
|
+
|
|
329
|
+
const features = [];
|
|
330
|
+
if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
|
|
331
|
+
if (options.dark) features.push({ name: "prefers-color-scheme", value: "dark" });
|
|
332
|
+
if (features.length) await call("Emulation.setEmulatedMedia", { features });
|
|
333
|
+
|
|
334
|
+
if (options.mobile) {
|
|
335
|
+
await call("Emulation.setDeviceMetricsOverride", {
|
|
336
|
+
width: 390,
|
|
337
|
+
height: 844,
|
|
338
|
+
deviceScaleFactor: 3,
|
|
339
|
+
mobile: true,
|
|
340
|
+
});
|
|
341
|
+
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
345
|
+
|
|
346
|
+
const before = await call("Performance.getMetrics");
|
|
347
|
+
await call("Page.navigate", { url: options.url });
|
|
348
|
+
await wait(options.wait);
|
|
349
|
+
|
|
350
|
+
for (const step of options.steps) {
|
|
351
|
+
if (step.type === "scroll") {
|
|
352
|
+
const pixels = asNumber(step.value);
|
|
353
|
+
const moved = await evaluate(
|
|
354
|
+
pixels === null
|
|
355
|
+
? `(() => {
|
|
356
|
+
const element = document.querySelector(${JSON.stringify(step.value)});
|
|
357
|
+
if (!element) return null;
|
|
358
|
+
element.scrollIntoView({ block: "center" });
|
|
359
|
+
return Math.round(window.scrollY);
|
|
360
|
+
})()`
|
|
361
|
+
: `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
|
|
362
|
+
);
|
|
363
|
+
notes.push(
|
|
364
|
+
moved === null
|
|
365
|
+
? `scroll: nothing matched ${step.value}`
|
|
366
|
+
: `scroll: to ${moved}px`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (step.type === "hover" || step.type === "click") {
|
|
371
|
+
const box = await evaluate(boxOf(step.value));
|
|
372
|
+
if (!box) {
|
|
373
|
+
notes.push(`${step.type}: nothing matched ${step.value}`);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Two moves, because a pointer that is already there never enters; and
|
|
378
|
+
* the box is read now, since whatever is animating has moved since the
|
|
379
|
+
* page loaded.
|
|
380
|
+
*/
|
|
381
|
+
await call("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1, y: 1, buttons: 0 });
|
|
382
|
+
await wait(60);
|
|
383
|
+
await call("Input.dispatchMouseEvent", {
|
|
384
|
+
type: "mouseMoved",
|
|
385
|
+
x: box.x,
|
|
386
|
+
y: box.y,
|
|
387
|
+
buttons: 0,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (step.type === "click") {
|
|
391
|
+
for (const type of ["mousePressed", "mouseReleased"]) {
|
|
392
|
+
await call("Input.dispatchMouseEvent", {
|
|
393
|
+
type,
|
|
394
|
+
x: box.x,
|
|
395
|
+
y: box.y,
|
|
396
|
+
button: "left",
|
|
397
|
+
buttons: type === "mousePressed" ? 1 : 0,
|
|
398
|
+
clickCount: 1,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
notes.push(
|
|
403
|
+
`${step.type}: ${step.value} at ${Math.round(box.x)},${Math.round(box.y)}`,
|
|
404
|
+
);
|
|
405
|
+
await wait(120);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
mkdirSync(resolve(options.out), { recursive: true });
|
|
410
|
+
|
|
411
|
+
const frames = [];
|
|
412
|
+
let elapsed = 0;
|
|
413
|
+
for (const at of options.at) {
|
|
414
|
+
if (at > elapsed) {
|
|
415
|
+
await wait(at - elapsed);
|
|
416
|
+
elapsed = at;
|
|
417
|
+
}
|
|
418
|
+
const shot = await call("Page.captureScreenshot", { format: "png" });
|
|
419
|
+
const bytes = Buffer.from(shot.data, "base64");
|
|
420
|
+
const file = join(resolve(options.out), `frame-${String(at).padStart(5, "0")}ms.png`);
|
|
421
|
+
writeFileSync(file, bytes);
|
|
422
|
+
frames.push({ at, file, bytes: bytes.length });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const timeline = await evaluate(READ_TIMELINE);
|
|
426
|
+
const after = await call("Performance.getMetrics");
|
|
427
|
+
|
|
428
|
+
const delta = (name) => {
|
|
429
|
+
const start = before.metrics.find((metric) => metric.name === name)?.value ?? 0;
|
|
430
|
+
const end = after.metrics.find((metric) => metric.name === name)?.value ?? 0;
|
|
431
|
+
return end - start;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const span = (options.at.at(-1) ?? 0) + options.wait;
|
|
435
|
+
const measured = {
|
|
436
|
+
layouts: delta("LayoutCount"),
|
|
437
|
+
recalcs: delta("RecalcStyleCount"),
|
|
438
|
+
frames: delta("Frames"),
|
|
439
|
+
layoutSeconds: Number(delta("LayoutDuration").toFixed(3)),
|
|
440
|
+
recalcSeconds: Number(delta("RecalcStyleDuration").toFixed(3)),
|
|
441
|
+
overMs: span,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return { url: options.url, frames, timeline, measured, notes, chrome: executable };
|
|
445
|
+
} finally {
|
|
446
|
+
await browser.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Reporting ---------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
function report(result, options) {
|
|
453
|
+
const lines = [`Watched ${result.url}`];
|
|
454
|
+
|
|
455
|
+
const emulated = [
|
|
456
|
+
options.reduced && "reduced motion",
|
|
457
|
+
options.dark && "dark",
|
|
458
|
+
options.mobile && "mobile 390x844",
|
|
459
|
+
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
460
|
+
].filter(Boolean);
|
|
461
|
+
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
462
|
+
for (const note of result.notes) lines.push(` ${note}`);
|
|
463
|
+
|
|
464
|
+
lines.push("", `Frames (${result.frames.length}), in ${options.out}`);
|
|
465
|
+
for (const frame of result.frames) {
|
|
466
|
+
lines.push(` ${String(frame.at).padStart(5)}ms ${frame.file}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
lines.push("", "Timeline, as the page is running it");
|
|
470
|
+
if (result.timeline === null) {
|
|
471
|
+
lines.push(" no GSAP on the page — nothing was animating, or it never loaded");
|
|
472
|
+
} else if (result.timeline.children.length === 0) {
|
|
473
|
+
lines.push(" GSAP is loaded, and its global timeline has no children right now");
|
|
474
|
+
} else {
|
|
475
|
+
for (const child of result.timeline.children) {
|
|
476
|
+
const forever = child.repeat === -1;
|
|
477
|
+
lines.push(
|
|
478
|
+
` ${child.targets.join(", ") || "(no target)"} · ${child.duration}s` +
|
|
479
|
+
`${forever ? ", repeats forever" : child.repeat ? `, repeats ${child.repeat}` : ""}` +
|
|
480
|
+
`${child.yoyo ? ", yoyo" : ""}${child.paused ? ", paused" : ""}` +
|
|
481
|
+
`${child.ease ? `, ease ${child.ease}` : ""}` +
|
|
482
|
+
`${child.props.length ? ` · ${child.props.join(" ")}` : ""}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const { measured } = result;
|
|
488
|
+
lines.push("", `Browser work over ${measured.overMs}ms`);
|
|
489
|
+
lines.push(
|
|
490
|
+
` ${measured.frames} frames · ${measured.layouts} layouts (${measured.layoutSeconds}s)` +
|
|
491
|
+
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
492
|
+
);
|
|
493
|
+
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
494
|
+
|
|
495
|
+
return lines.join("\n");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- Entry -------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
async function main(argv) {
|
|
501
|
+
const options = parseOptions(argv);
|
|
502
|
+
|
|
503
|
+
if (options.errors.length) {
|
|
504
|
+
for (const error of options.errors) console.error(error);
|
|
505
|
+
console.error("\nUsage: node capture-motion.mjs <url> [--at 0,300,900] [--hover <sel>] …");
|
|
506
|
+
process.exit(2);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const result = await capture(options);
|
|
511
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
|
|
512
|
+
} catch (error) {
|
|
513
|
+
console.error(error.message);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
519
|
+
if (invoked) await main(process.argv.slice(2));
|