@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.
@@ -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));