awwwards-mcp 1.0.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 +106 -0
- package/dist/awwwards.js +113 -0
- package/dist/cache.js +134 -0
- package/dist/capture.js +51 -0
- package/dist/cli.js +103 -0
- package/dist/index-cli.js +30 -0
- package/dist/indexer.js +116 -0
- package/dist/motion.js +360 -0
- package/dist/parsers.js +161 -0
- package/dist/server.js +488 -0
- package/dist/structure.js +208 -0
- package/dist/types.js +1 -0
- package/package.json +39 -0
- package/skills/awwwards-inspiration/SKILL.md +136 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { CAPTURE_INSTALL_HINT } from "./capture.js";
|
|
2
|
+
// Runs IN THE PAGE via page.evaluate. Collects full-width, tall, opaque
|
|
3
|
+
// elements as band candidates; body is always the base candidate. Gradient
|
|
4
|
+
// shorthand backgrounds leave backgroundColor transparent — such sections
|
|
5
|
+
// fall through to the nearest opaque ancestor (usually body).
|
|
6
|
+
//
|
|
7
|
+
// page.evaluate serializes plain data only, so this is a single
|
|
8
|
+
// self-contained function returning { title, totalHeight, candidates }
|
|
9
|
+
// (no closures over module scope). The Node tsconfig has no DOM lib, so
|
|
10
|
+
// browser globals are reached through globalThis.
|
|
11
|
+
//
|
|
12
|
+
// The backgroundColor alpha parsing in the visit loop (including the
|
|
13
|
+
// modern-color-function fallback) is mirrored in test/structure.test.ts
|
|
14
|
+
// ("SCAN_SNIPPET alpha parsing handles space syntax and percentage alphas")
|
|
15
|
+
// and must stay in sync.
|
|
16
|
+
export const SCAN_SNIPPET = () => {
|
|
17
|
+
const g = globalThis;
|
|
18
|
+
const doc = g.document;
|
|
19
|
+
const totalHeight = Math.round(doc.documentElement.scrollHeight);
|
|
20
|
+
const bodyW = doc.body.getBoundingClientRect().width || 1;
|
|
21
|
+
const bodyBg = g.getComputedStyle(doc.body).backgroundColor;
|
|
22
|
+
const candidates = [
|
|
23
|
+
{
|
|
24
|
+
tag: "body",
|
|
25
|
+
label: "body",
|
|
26
|
+
bg: bodyBg,
|
|
27
|
+
top: 0,
|
|
28
|
+
height: totalHeight,
|
|
29
|
+
textStart: "",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const visit = (el, depth) => {
|
|
34
|
+
if (depth > 6 || candidates.length >= 300 || seen.has(el))
|
|
35
|
+
return;
|
|
36
|
+
seen.add(el);
|
|
37
|
+
const r = el.getBoundingClientRect();
|
|
38
|
+
if (el !== doc.body && r.width >= 0.6 * bodyW && r.height >= 120) {
|
|
39
|
+
const s = g.getComputedStyle(el);
|
|
40
|
+
const color = s.backgroundColor;
|
|
41
|
+
// Alpha parse tolerant of legacy comma syntax and CSS Color 4
|
|
42
|
+
// space syntax: rgb(r g b / a), rgb(r, g, b, a), and percentage alphas.
|
|
43
|
+
// A rgba?() miss falls back by color function: modern opaque functions
|
|
44
|
+
// (oklch/oklab/lab/lch/hwb/color) count as opaque (alpha 1); anything
|
|
45
|
+
// else (gradients, keywords) stays conservative at alpha 0.
|
|
46
|
+
const m = /rgba?\(([^)]+)\)/.exec(color);
|
|
47
|
+
let alpha = 0;
|
|
48
|
+
if (m) {
|
|
49
|
+
const parts = m[1].replace(/\//g, " ").trim().split(/[\s,]+/).filter(Boolean);
|
|
50
|
+
const aRaw = parts.length >= 4 ? parts[3] : "1";
|
|
51
|
+
alpha = aRaw.endsWith("%") ? parseFloat(aRaw) / 100 : parseFloat(aRaw);
|
|
52
|
+
if (Number.isNaN(alpha))
|
|
53
|
+
alpha = 0;
|
|
54
|
+
}
|
|
55
|
+
else if (/^(oklch|oklab|lab|lch|hwb|color)\(/.test(color.trim())) {
|
|
56
|
+
alpha = 1;
|
|
57
|
+
}
|
|
58
|
+
if (alpha > 0) {
|
|
59
|
+
candidates.push({
|
|
60
|
+
tag: el.tagName.toLowerCase(),
|
|
61
|
+
label: (el.id ? "#" + el.id : "") + (el.classList.length ? "." + String(el.classList[0]) : ""),
|
|
62
|
+
bg: s.backgroundColor,
|
|
63
|
+
top: Math.round(r.top + g.scrollY),
|
|
64
|
+
height: Math.round(r.height),
|
|
65
|
+
textStart: (el.textContent ?? "").trim().slice(0, 60),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const child of el.children)
|
|
70
|
+
visit(child, depth + 1);
|
|
71
|
+
};
|
|
72
|
+
visit(doc.body, 0);
|
|
73
|
+
return { title: doc.title, totalHeight, candidates };
|
|
74
|
+
};
|
|
75
|
+
// PURE: reduces raw candidates to the page's visible horizontal band map.
|
|
76
|
+
// Sweeps y in 8px steps; at each y the effective background is the covering
|
|
77
|
+
// candidate with the SMALLEST height (most specific wins). Consecutive
|
|
78
|
+
// same-background runs merge (first label wins); bands < 40px are absorbed
|
|
79
|
+
// into the previous band; the count is capped by merging the smallest band
|
|
80
|
+
// into its previous neighbor. Uncovered y (gaps) never emit a band.
|
|
81
|
+
export function collapseBands(cands, totalHeight, maxBands = 40) {
|
|
82
|
+
// Round candidate geometry up front: the sweep samples integer y and the
|
|
83
|
+
// emitted coordinates must be rounded (top 0.4 must yield a band at 0).
|
|
84
|
+
const usable = cands
|
|
85
|
+
.map((c) => ({ ...c, top: Math.round(c.top), height: Math.round(c.height) }))
|
|
86
|
+
.filter((c) => c.height > 0);
|
|
87
|
+
const STEP = 8;
|
|
88
|
+
const bgAt = (y) => {
|
|
89
|
+
let best = null;
|
|
90
|
+
for (const c of usable) {
|
|
91
|
+
if (c.top <= y && y < c.top + c.height) {
|
|
92
|
+
if (!best || c.height < best.height)
|
|
93
|
+
best = c;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return best;
|
|
97
|
+
};
|
|
98
|
+
const runs = [];
|
|
99
|
+
let y = 0;
|
|
100
|
+
let current = null;
|
|
101
|
+
while (y < totalHeight) {
|
|
102
|
+
const c = bgAt(y);
|
|
103
|
+
if (current && c && c.bg === current.cand.bg) {
|
|
104
|
+
// same run continues
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
if (current)
|
|
108
|
+
runs.push({ cand: current.cand, top: current.top, height: y - current.top });
|
|
109
|
+
current = c ? { cand: c, top: y } : null;
|
|
110
|
+
if (!c) {
|
|
111
|
+
// no candidate covers y (gap): nothing is emitted; when coverage
|
|
112
|
+
// resumes the merge pass below re-joins same-bg neighbors.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
y += STEP;
|
|
116
|
+
}
|
|
117
|
+
if (current)
|
|
118
|
+
runs.push({ cand: current.cand, top: current.top, height: totalHeight - current.top });
|
|
119
|
+
// Merge adjacent same-bg runs (first label wins), absorb tiny slivers.
|
|
120
|
+
// Absorbing a sliver seals the seam so a same-bg run after the sliver
|
|
121
|
+
// stays its own band instead of bleeding across it.
|
|
122
|
+
const merged = [];
|
|
123
|
+
for (const r of runs) {
|
|
124
|
+
const prev = merged[merged.length - 1];
|
|
125
|
+
if (prev && !prev.sealed && prev.cand.bg === r.cand.bg)
|
|
126
|
+
prev.height = r.top + r.height - prev.top;
|
|
127
|
+
else if (r.height < 40 && prev) {
|
|
128
|
+
prev.height = r.top + r.height - prev.top;
|
|
129
|
+
prev.sealed = true;
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
merged.push({ ...r });
|
|
133
|
+
}
|
|
134
|
+
// Cap: merge the smallest band into its previous neighbor until <= maxBands.
|
|
135
|
+
while (merged.length > maxBands && merged.length > 1) {
|
|
136
|
+
let idx = 1;
|
|
137
|
+
for (let i = 1; i < merged.length; i++)
|
|
138
|
+
if (merged[i].height < merged[idx].height)
|
|
139
|
+
idx = i;
|
|
140
|
+
merged[idx - 1].height = merged[idx - 1].height + merged[idx].height;
|
|
141
|
+
merged.splice(idx, 1);
|
|
142
|
+
}
|
|
143
|
+
return merged.map((r, i) => ({
|
|
144
|
+
index: i,
|
|
145
|
+
tag: r.cand.tag,
|
|
146
|
+
label: r.cand.label,
|
|
147
|
+
background: r.cand.bg,
|
|
148
|
+
offsetTop: Math.round(r.top),
|
|
149
|
+
height: Math.round(r.height),
|
|
150
|
+
textStart: r.cand.textStart,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
// Scroll through the page so lazy-rendered sections have layout before a
|
|
154
|
+
// screenshot or band scan (spec requirement; 450px steps, brief settle, back
|
|
155
|
+
// to top). Shared by captureLiveSite and analyzePageStructure — this is the
|
|
156
|
+
// ONE implementation; capture.ts imports it.
|
|
157
|
+
export async function preScroll(page) {
|
|
158
|
+
await page.evaluate(async () => {
|
|
159
|
+
const step = 450;
|
|
160
|
+
for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
|
|
161
|
+
window.scrollTo(0, y);
|
|
162
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
163
|
+
}
|
|
164
|
+
window.scrollTo(0, 0);
|
|
165
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export async function analyzePageStructure(url, loader = () => import("playwright"), maxBands, opts) {
|
|
169
|
+
let chromium;
|
|
170
|
+
try {
|
|
171
|
+
({ chromium } = await loader());
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return { error: CAPTURE_INSTALL_HINT };
|
|
175
|
+
}
|
|
176
|
+
let browser;
|
|
177
|
+
try {
|
|
178
|
+
browser = await chromium.launch();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { error: CAPTURE_INSTALL_HINT };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const waitStrategy = opts?.waitStrategy ?? "load";
|
|
185
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
186
|
+
await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
|
|
187
|
+
// "load" can fire before late XHRs settle, so give the page a fixed
|
|
188
|
+
// settle window; networkidle already means the network went quiet.
|
|
189
|
+
if (waitStrategy === "load")
|
|
190
|
+
await page.waitForTimeout(3000);
|
|
191
|
+
await preScroll(page);
|
|
192
|
+
const raw = (await page.evaluate(SCAN_SNIPPET));
|
|
193
|
+
return {
|
|
194
|
+
url,
|
|
195
|
+
title: raw.title,
|
|
196
|
+
totalHeight: raw.totalHeight,
|
|
197
|
+
bands: collapseBands(raw.candidates, raw.totalHeight, maxBands ?? 40),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
try {
|
|
202
|
+
await browser.close();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* keep the primary error */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "awwwards-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Free MCP server giving AI agents design inspiration from Awwwards: search award-winning sites with inline screenshots and extract design DNA.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"awwwards-mcp": "dist/cli.js",
|
|
9
|
+
"awwwards-index": "dist/index-cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"skills"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.13.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"index": "node dist/index-cli.js",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"smoke": "tsx test/live-smoke.ts",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
|
+
"zod": "^3.24.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"ffmpeg-static": "^5.3.0",
|
|
34
|
+
"playwright": "^1.63.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"typescript": "^5.6.0",
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: awwwards-inspiration
|
|
3
|
+
description: Use when building a site that needs design references (e.g. "make it feel premium", "dark 3D portfolio vibes"), when the user asks for standalone inspiration research ("show me award-winning e-commerce sites", "what's trending in brutalism"), or when setting up or maintaining the awwwards-mcp local index or live-capture tooling (awwwards-index, capture_live_site).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Awwwards Inspiration
|
|
7
|
+
|
|
8
|
+
You have access to the `awwwards` MCP server: a searchable library of
|
|
9
|
+
award-winning websites with inline screenshots and per-site design DNA. Use
|
|
10
|
+
it to ground design decisions in real, proven references instead of guessing.
|
|
11
|
+
|
|
12
|
+
## When to use this skill
|
|
13
|
+
|
|
14
|
+
1. **During site builds** — before writing any UI code, gather references and
|
|
15
|
+
state a design direction.
|
|
16
|
+
2. **Standalone research** — the user wants inspiration, trends, or examples
|
|
17
|
+
("show me dark 3D portfolio sites").
|
|
18
|
+
3. **Index & capture ops** — building the local search index, or taking fresh
|
|
19
|
+
screenshots of live URLs.
|
|
20
|
+
|
|
21
|
+
## The inspiration loop
|
|
22
|
+
|
|
23
|
+
Run this loop before building anything visual:
|
|
24
|
+
|
|
25
|
+
1. **Restate the goal as concrete attributes.** Turn the user's request into
|
|
26
|
+
mood, color, technology, and industry terms. "Make it feel premium" becomes
|
|
27
|
+
e.g. "dark, elegant, WebGL, agency portfolio".
|
|
28
|
+
2. **Ground your vocabulary.** If unsure which filters exist, call
|
|
29
|
+
`list_categories` first — it returns every color hex and tag/technology
|
|
30
|
+
slug you can search by.
|
|
31
|
+
3. **Search.** Call `search_sites` with 1–3 filters (e.g.
|
|
32
|
+
`{ color: "#404040", tags: ["3d", "portfolio"] }`). Judge the results from
|
|
33
|
+
the inline screenshots, not just titles. Shortlist 2–3 candidates.
|
|
34
|
+
4. **Live reference URL named? Capture it full-page first.** (and later capture
|
|
35
|
+
your own build the same way — compare both against each other) If the user
|
|
36
|
+
points at a specific live site (e.g. "recreate cerebrium.ai"), call
|
|
37
|
+
`capture_live_site` on that URL before anything else and design from the
|
|
38
|
+
full-page PNG — every section, top to bottom. Cached Awwwards screenshots
|
|
39
|
+
are hero-only crops (~880×660) and hide everything below the fold: the
|
|
40
|
+
sections that make a site's structure distinctive (pricing, feature
|
|
41
|
+
layouts, contrast breaks, footer) were never visible in them.
|
|
42
|
+
5. **Get the design DNA.** Call `get_site_details` on the top pick for its
|
|
43
|
+
palette, technologies, design elements, awards, and description. If it
|
|
44
|
+
reports a layout-drift error, fall back to judging the shortlisted
|
|
45
|
+
screenshots, `get_site_elements` (which uses a different parser), or a
|
|
46
|
+
`capture_live_site` of the site's URL for a first-hand full-page view.
|
|
47
|
+
6. **Get component-level visuals (when building).** Call `get_site_elements`
|
|
48
|
+
on shortlisted sites to see individual design elements — 3D models, video
|
|
49
|
+
content, mobile layouts, microcopy — with poster images inline and video
|
|
50
|
+
URLs. Treat element posters as texture, not structure: they are video
|
|
51
|
+
frames (often mid-animation or near-black), not full-section layouts.
|
|
52
|
+
7. **State the design direction before writing code.** In prose: palette
|
|
53
|
+
(hexes from the references), type mood, layout patterns, and tech choices,
|
|
54
|
+
each traceable to a reference. Then build.
|
|
55
|
+
8. **Verify structure, then polish.** After building, capture your own build
|
|
56
|
+
full-page (`capture_live_site` on its `file://` or served URL) and run
|
|
57
|
+
`analyze_page_structure` on BOTH the reference and the build. Compare band
|
|
58
|
+
maps section by section (count, order, backgrounds, heights). Fix
|
|
59
|
+
distribution mismatches first — a section that is 3× the reference's height
|
|
60
|
+
is a structural bug no amount of pixel polish fixes. Match the reference's
|
|
61
|
+
band structure, never just its total height.
|
|
62
|
+
|
|
63
|
+
### Anti-patterns
|
|
64
|
+
|
|
65
|
+
- **Vague single-word searches** ("modern", "nice") — use concrete color/tag/
|
|
66
|
+
technology/award filters instead.
|
|
67
|
+
- **Skipping to code** without stating a direction — the references are
|
|
68
|
+
worthless if nothing is derived from them.
|
|
69
|
+
- **Dumping raw tool output at the user** — curate: show the shortlist, the
|
|
70
|
+
chosen direction, and why.
|
|
71
|
+
- **Designing from a thumbnail or element poster** — thumbnails are hero-only
|
|
72
|
+
crops and posters are video frames; neither shows the page's real
|
|
73
|
+
structure. When the reference URL is known, capture it full-page (step 4).
|
|
74
|
+
- **Padding empty bands to match total height** — if your build's total height
|
|
75
|
+
matches the reference but a spacer/background band is far taller than the
|
|
76
|
+
reference's equivalent, the height was stolen from real content sections.
|
|
77
|
+
Compare band maps, not totals. Spacer and decorative elements are measured
|
|
78
|
+
against the reference's equivalent band — never invented to absorb height.
|
|
79
|
+
|
|
80
|
+
## Tool reference
|
|
81
|
+
|
|
82
|
+
| Tool | Key params | Returns | Gotchas |
|
|
83
|
+
|------|-----------|---------|---------|
|
|
84
|
+
| `search_sites` | `query` (free text vs titles/tags), `color` (hex like `#404040`), `tags` (array of slugs), `technology` (slug), `award` (`sotd`\|`developer`\|`honorable`), `count` (1–12, default 6), `page` (default 1) | Text list of site cards (title, slug, live URL, awards, tags) + inline JPEG screenshots | Awwwards applies only one URL filter — priority color > award > technology > first tag; the rest are checked client-side. Color searches always scrape live (never cached). Deep pagination is unavailable by design (robots.txt). On live-request failure, stale cache is served when present. |
|
|
85
|
+
| `get_site_details` | `slug` (from `search_sites`, e.g. `l-i-s-a`) | Title, live URL, awards, color palette, technologies, design elements, description, full-size screenshot URL; inline screenshot when available | Cached 7 days; a parse that comes back all-empty is an error, not a quiet empty result. |
|
|
86
|
+
| `get_site_elements` | `slug` | Numbered element list (image or video, with video URLs) + up to 8 inline poster JPEGs | Videos are mp4 URLs (posters only are shown inline). Elements feed from the same fetch as `get_site_details`. |
|
|
87
|
+
| `list_categories` | none | JSON: every color hex and filter/tag slug, plus usage guidance | Cached 30 days. Call this whenever filter vocabulary is uncertain. |
|
|
88
|
+
| `capture_live_site` | `url` (absolute URL) | Full-page PNG saved to disk + inline image | Requires the optional playwright dependency (`npm install -g playwright && npx playwright install chromium`). |
|
|
89
|
+
| `analyze_page_structure` | `url` (absolute URL or `file://` path), `maxBands` (cap on returned bands, default 40) | JSON: `title`, `totalHeight`, and an ordered band map (`index`, `tag`, `label`, `background`, `offsetTop`, `height`, `textStart` (first ~60 chars of the band's text) per band) | Requires playwright. Run it on BOTH the reference and your build (step 8) and compare band maps — count, order, backgrounds, heights — never just total height. |
|
|
90
|
+
| `record_site_motion` | `url` (absolute URL), `frames` (filmstrip tile count, 4–36, default 16 → a 4x4 grid) | Inline filmstrip JPEG of a motion-through pass (preloader dwell, slow scroll, hover/cursor interactions) + the saved .webm path as text | Requires playwright + ffmpeg-static. Runs a ~30 s scripted pass — heavier than a capture, use when motion matters (step on from static captures). |
|
|
91
|
+
|
|
92
|
+
All image results arrive as MCP image content blocks — look at them, don't
|
|
93
|
+
just read the text blocks.
|
|
94
|
+
|
|
95
|
+
## Index & capture ops
|
|
96
|
+
|
|
97
|
+
**Local index.** `search_sites` works out of the box but unindexed depth is
|
|
98
|
+
limited by polite live scraping (~31 sites per filter page). Build the index
|
|
99
|
+
once for searches across thousands of sites:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx -y -p awwwards-mcp awwwards-index # from the published package
|
|
103
|
+
npm run index # from a repo checkout
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Crawls all ~200 tag pages at 1 request/second (~4 minutes) into a SQLite
|
|
107
|
+
cache at `~/.awwwards-mcp/`.
|
|
108
|
+
- Resumable: interrupt and re-run; completed pages are skipped.
|
|
109
|
+
- The MCP server re-indexes automatically in the background whenever the
|
|
110
|
+
index is stale — you rarely need to run this by hand.
|
|
111
|
+
|
|
112
|
+
**Live captures.** `capture_live_site` needs playwright installed once (see
|
|
113
|
+
table above). Two uses: (1) the user wants a screenshot of a URL that is not
|
|
114
|
+
an Awwwards site, or a fresher view than the cached thumbnails; and (2) —
|
|
115
|
+
the more important one — the user names a live site as the design reference
|
|
116
|
+
for a build: capture it full-page first and derive the structure from that
|
|
117
|
+
image (see step 4 of the loop).
|
|
118
|
+
|
|
119
|
+
**Motion capture.** Static images can't show preloaders, scroll-driven
|
|
120
|
+
animation, or transitions — and most award-winning sites are built around
|
|
121
|
+
exactly those. When the reference site has motion, record it: launch
|
|
122
|
+
Playwright with `recordVideo`, wait out the preloader (~7 s), scroll slowly
|
|
123
|
+
to the bottom in small steps so every scroll-triggered animation fires on
|
|
124
|
+
camera, then extract a filmstrip of frames with ffmpeg (`npm i -D
|
|
125
|
+
ffmpeg-static`, one frame every ~3 s) and Read the frames — or call
|
|
126
|
+
`record_site_motion` directly: the filmstrip comes back inline and the .webm
|
|
127
|
+
path as text.
|
|
128
|
+
|
|
129
|
+
**Filmstrip is the fallback, not the default.** First try Reading the
|
|
130
|
+
video file directly — if your model supports video input, watching the
|
|
131
|
+
scroll-through gives you timing, easing, and transitions the frames can't.
|
|
132
|
+
If the Read comes back with media omitted / "model does not support video
|
|
133
|
+
input", extract the filmstrip and Read the frames instead. A ready-made
|
|
134
|
+
recorder ships in this repo at
|
|
135
|
+
`scripts/record-scrollthrough.mjs` (run it from the repo root; playwright
|
|
136
|
+
and ffmpeg-static are devDependencies).
|